functions/Get-XdrIdentityUser.ps1

function Get-XdrIdentityUser {
    <#
    .SYNOPSIS
        Retrieves detailed user identity information from Microsoft Defender for Identity.

    .DESCRIPTION
        Gets comprehensive user identity information by resolving identifiers across multiple
        workloads including Microsoft Graph, RADIUS, MCAS, MTP, MDI, and Sentinel.

        This cmdlet calls the user/resolve API to get full identity details including:
        - All user identifiers (AAD ID, SID, UPN, radiusUserId, complexId, armId)
        - User profile information (displayName, email, phone, department, jobTitle)
        - Security information (riskLevel, status, PIM roles)
        - Activity timestamps (firstSeen, lastSeen, created)
        - Cloud app accounts, activity period, devices count, and manager details when available

        The returned object can be piped to Get-XdrIdentityUserTimeline for timeline retrieval.

    .PARAMETER AadId
        The Azure AD object ID of the user.

    .PARAMETER Upn
        The User Principal Name (email address) of the user.

    .PARAMETER Sid
        The Security Identifier (SID) of the user.

    .PARAMETER RadiusUserId
        The RADIUS user ID in format "User_{tenantId}_{userId}".

    .PARAMETER Force
        Bypass cache and force a fresh API call.

    .EXAMPLE
        Get-XdrIdentityUser -Upn "nathan@contoso.com"

        Retrieves user identity information by UPN.

    .EXAMPLE
        Get-XdrIdentityUser -AadId "a2307c5a-76df-4513-b575-0537842c1d8b"

        Retrieves user identity information by Azure AD object ID.

    .EXAMPLE
        Get-XdrIdentityUser -Upn "nathan@contoso.com"

        Retrieves user identity including enrichment data (accounts, activity period, devices count, manager when available).

    .EXAMPLE
        Get-XdrIdentityUser -Upn "nathan@contoso.com" | Get-XdrIdentityUserTimeline -LastNDays 7

        Retrieves user identity and pipes to timeline cmdlet.

    .OUTPUTS
        XdrIdentityUser
        Returns a typed user identity object containing resolved identifiers and profile data.

    .NOTES
        The returned object contains an 'ids' property with all resolved identifiers that can
        be used with other identity cmdlets.
    #>

    [OutputType('XdrIdentityUser')]
    [CmdletBinding(DefaultParameterSetName = 'ByUpn')]
    param (
        [Parameter(Mandatory, ParameterSetName = 'ByAadId', ValueFromPipelineByPropertyName)]
        [Alias('aad', 'ObjectId')]
        [string]$AadId,

        [Parameter(Mandatory, ParameterSetName = 'ByUpn', ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('UserPrincipalName', 'Email')]
        [string]$Upn,

        [Parameter(Mandatory, ParameterSetName = 'BySid', ValueFromPipelineByPropertyName)]
        [string]$Sid,

        [Parameter(Mandatory, ParameterSetName = 'ByRadiusUserId', ValueFromPipelineByPropertyName)]
        [string]$RadiusUserId,

        [Parameter()]
        [switch]$Force
    )

    begin {
        Update-XdrConnectionSettings

        # Build headers required for MDI identity APIs
        $mdiHeaders = Get-XdrIdentityHeaders

        # Available workloads for resolve API
        $workloads = @("graph", "radius", "mcas", "mtp", "mdi", "sentinel")
    }

    process {
        # Build userIdentifiers based on parameter set
        $userIdentifiers = @{}

        switch ($PSCmdlet.ParameterSetName) {
            'ByAadId' {
                $userIdentifiers['aad'] = $AadId
                $cacheKey = "XdrIdentityUser_aad_$AadId"
            }
            'ByUpn' {
                $userIdentifiers['upn'] = $Upn
                $cacheKey = "XdrIdentityUser_upn_$Upn"
            }
            'BySid' {
                $userIdentifiers['sid'] = $Sid
                $cacheKey = "XdrIdentityUser_sid_$Sid"
            }
            'ByRadiusUserId' {
                $userIdentifiers['radiusUserId'] = $RadiusUserId
                $cacheKey = "XdrIdentityUser_radius_$RadiusUserId"
            }
        }

        $user = $null

        # Check cache unless Force is specified
        if (-not $Force) {
            $cached = Get-XdrCache -CacheKey $cacheKey -ErrorAction SilentlyContinue
            if ($cached -and $cached.NotValidAfter -gt (Get-Date)) {
                Write-Verbose "Returning cached user identity for $cacheKey"
                $user = $cached.Value

                $requiredEnrichmentProperties = @('accounts', 'activityPeriod', 'devicesCount', 'managerInfo')
                $missingEnrichmentProperties = @(
                    foreach ($property in $requiredEnrichmentProperties) {
                        if ($null -eq $user.PSObject.Properties[$property]) {
                            $property
                        }
                    }
                )

                if ($missingEnrichmentProperties.Count -eq 0) {
                    return $user
                }

                Write-Verbose "Cached user is missing enrichment properties ($($missingEnrichmentProperties -join ', ')); refreshing enrichment data."
            }
        }

        if ($null -eq $user) {
            # Call the resolve API
            $resolveUri = "https://security.microsoft.com/apiproxy/mdi/identity/userapiservice/user/resolve"
            $resolveBody = @{
                workloads       = $workloads
                userIdentifiers = $userIdentifiers
            }

            Write-Verbose "Resolving user identity via $resolveUri"
            Write-Verbose "Request body: $($resolveBody | ConvertTo-Json -Depth 5 -Compress)"

            try {
                $response = Invoke-RestMethod -Uri $resolveUri `
                    -Method POST `
                    -ContentType "application/json" `
                    -Body ($resolveBody | ConvertTo-Json -Depth 10) `
                    -WebSession $script:session `
                    -Headers $mdiHeaders `
                    -ErrorAction Stop

                if (-not $response -or -not $response.results) {
                    Write-Error -ErrorId 'XdrIdentityUserNotFound' `
                        -Category ObjectNotFound `
                        -TargetObject $userIdentifiers `
                        -Message "User resolve API returned no result for identifier: $($userIdentifiers | ConvertTo-Json -Compress)"
                    return
                }

                # Build the user object from results
                $user = $response.results

                # Add top-level errors and workloads info
                $user | Add-Member -NotePropertyName 'resolveErrors' -NotePropertyValue $response.errors -Force
                $user | Add-Member -NotePropertyName 'resolveWorkloads' -NotePropertyValue $response.workloads -Force

                # Add PSTypeName for formatting
                $user.PSObject.TypeNames.Insert(0, 'XdrIdentityUser')

            } catch {
                $fqid = [string]$_.FullyQualifiedErrorId
                if ($fqid -like 'XdrIdentityUserNotFound*' -or $fqid -like '*XdrIdentityUserNotFound*') {
                    Write-Error -ErrorRecord $_
                    return
                }

                $statusCode = $null
                if ($_.Exception.Response) {
                    $statusCode = [int]$_.Exception.Response.StatusCode
                }

                $errorId = 'XdrIdentityUserResolveFailed'
                $errorCategory = [System.Management.Automation.ErrorCategory]::ConnectionError
                if ($statusCode -in @(401, 403)) {
                    $errorId = 'XdrIdentityUserResolveUnauthorized'
                    $errorCategory = [System.Management.Automation.ErrorCategory]::SecurityError
                } elseif ($statusCode -eq 429) {
                    $errorId = 'XdrIdentityUserResolveThrottled'
                    $errorCategory = [System.Management.Automation.ErrorCategory]::LimitsExceeded
                } elseif ($statusCode -ge 500) {
                    $errorId = 'XdrIdentityUserResolveServerError'
                    $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation
                }

                Write-Error -Exception $_.Exception `
                    -ErrorId $errorId `
                    -Category $errorCategory `
                    -TargetObject $userIdentifiers `
                    -Message "Failed to resolve user identity. Status: $statusCode. $($_.Exception.Message)"
                return
            }
        }

        # Get the full userIdentifiers for enrichment API calls
        $fullIdentifiers = ConvertTo-XdrIdentityUserIdentifiers -ResolvedUser $user

        # Ensure enrichment properties are always present, even when calls fail.
        $user | Add-Member -NotePropertyName 'accounts' -NotePropertyValue @() -Force
        $user | Add-Member -NotePropertyName 'accountsWorkloads' -NotePropertyValue $null -Force
        $user | Add-Member -NotePropertyName 'activityPeriod' -NotePropertyValue $null -Force
        $user | Add-Member -NotePropertyName 'activityPeriodWorkloads' -NotePropertyValue $null -Force
        $user | Add-Member -NotePropertyName 'devicesCount' -NotePropertyValue $null -Force
        $user | Add-Member -NotePropertyName 'managerInfo' -NotePropertyValue $null -Force

        # Enrichment: Accounts
        Write-Verbose "Fetching cloud app accounts..."
        $accountsUri = "https://security.microsoft.com/apiproxy/mdi/identity/userapiservice/accounts"
        $accountsBody = @{
            userIdentifiers = $fullIdentifiers
        }

        try {
            $accountsResponse = Invoke-RestMethod -Uri $accountsUri `
                -Method POST `
                -ContentType "application/json" `
                -Body ($accountsBody | ConvertTo-Json -Depth 10) `
                -WebSession $script:session `
                -Headers $mdiHeaders `
                -ErrorAction Stop

            $user | Add-Member -NotePropertyName 'accounts' -NotePropertyValue $accountsResponse.results -Force
            $user | Add-Member -NotePropertyName 'accountsWorkloads' -NotePropertyValue $accountsResponse.workloads -Force
        } catch {
            Write-Warning "Failed to retrieve accounts: $_"
        }

        # Enrichment: Activity Period
        $hasActivityIdentifier = (
            (-not [string]::IsNullOrWhiteSpace([string]$fullIdentifiers.ad)) -or
            ($null -ne $fullIdentifiers.complexId) -or
            (-not [string]::IsNullOrWhiteSpace([string]$fullIdentifiers.sid)) -or
            (
                (-not [string]::IsNullOrWhiteSpace([string]$fullIdentifiers.thirdPartyProviderAccountId)) -and
                (-not [string]::IsNullOrWhiteSpace([string]$fullIdentifiers.thirdPartyIdentityProvider))
            )
        )

        if (-not $hasActivityIdentifier) {
            Write-Verbose 'Skipping activity period lookup because required identifiers are missing (ad, complexId, sid, or third-party identity pair).'
        } else {
            Write-Verbose "Fetching activity period..."
            $activityUri = "https://security.microsoft.com/apiproxy/mdi/identity/userapiservice/user/activityPeriod"
            $activityBody = @{
                CurrentFirstSeen = $user.firstSeen
                CurrentLastSeen  = $user.lastSeen
                UserIdentifiers  = $fullIdentifiers
            }

            try {
                $activityResponse = Invoke-RestMethod -Uri $activityUri `
                    -Method POST `
                    -ContentType "application/json" `
                    -Body ($activityBody | ConvertTo-Json -Depth 10) `
                    -WebSession $script:session `
                    -Headers $mdiHeaders `
                    -ErrorAction Stop

                $user | Add-Member -NotePropertyName 'activityPeriod' -NotePropertyValue $activityResponse.results -Force
                $user | Add-Member -NotePropertyName 'activityPeriodWorkloads' -NotePropertyValue $activityResponse.workloads -Force

                # Update firstSeen/lastSeen with more accurate values
                if ($activityResponse.results.firstSeen) {
                    $user.firstSeen = $activityResponse.results.firstSeen
                }
                if ($activityResponse.results.lastSeen) {
                    $user.lastSeen = $activityResponse.results.lastSeen
                }
            } catch {
                Write-Warning "Failed to retrieve activity period: $_"
            }
        }

        # Enrichment: Devices Count
        Write-Verbose "Fetching devices count..."
        $devicesUri = "https://security.microsoft.com/apiproxy/mdi/identity/userapiservice/devices/count"
        $devicesBody = @{
            userIdentifiers      = $fullIdentifiers
            adServiceAccountType = $user.adServiceAccountType
            filters              = @{}
            limit                = 1000
        }

        try {
            $devicesResponse = Invoke-RestMethod -Uri $devicesUri `
                -Method POST `
                -ContentType "application/json" `
                -Body ($devicesBody | ConvertTo-Json -Depth 10) `
                -WebSession $script:session `
                -Headers $mdiHeaders `
                -ErrorAction Stop

            $user | Add-Member -NotePropertyName 'devicesCount' -NotePropertyValue $devicesResponse.results -Force
        } catch {
            Write-Warning "Failed to retrieve devices count: $_"
        }

        # Enrichment: Manager
        $managerAd = [string]$user.managerId
        $userAad = [string]$user.ids.aad

        if ([string]::IsNullOrWhiteSpace($managerAd) -or [string]::IsNullOrWhiteSpace($userAad)) {
            Write-Verbose 'Skipping manager lookup because managerId or user AAD ID is missing.'
        } else {
            Write-Verbose "Fetching manager information..."
            $encodedManagerAd = [System.Uri]::EscapeDataString($managerAd)
            $encodedUserAad = [System.Uri]::EscapeDataString($userAad)
            $managerUri = "https://security.microsoft.com/apiproxy/mdi/identity/userapiservice/manager?managerAd=$encodedManagerAd&userAad=$encodedUserAad"

            try {
                $managerResponse = Invoke-RestMethod -Uri $managerUri `
                    -Method GET `
                    -ContentType "application/json" `
                    -WebSession $script:session `
                    -Headers $mdiHeaders `
                    -ErrorAction Stop

                if ($managerResponse.results -and $managerResponse.results.displayName) {
                    $user | Add-Member -NotePropertyName 'managerInfo' -NotePropertyValue $managerResponse.results -Force
                }
            } catch {
                Write-Warning "Failed to retrieve manager: $_"
            }
        }

        # Cache the result (10 minute TTL)
        Set-XdrCache -CacheKey $cacheKey -Value $user -TTLMinutes 10

        return $user
    }
}