Private/RoleManagement/Azure/Get-AzureResourceRoles.ps1

function Get-AzureResourceRoles {
    <#
    .SYNOPSIS
        Retrieves Azure Resource PIM eligible and active roles for the calling user
        using the ARM root-scope endpoints with the asTarget() filter.
 
    .DESCRIPTION
        Calls the ARM PIM schedule-instance endpoints at the root tenant scope
        ("/providers/Microsoft.Authorization/...") with the $filter=asTarget()
        clause. asTarget() returns every PIM eligibility (or activation) the
        calling user holds tenant-wide - across management groups, subscriptions,
        resource groups, and individual resources - without requiring any
        pre-existing Azure role at the queried scope (avoids the historic
        Azure Resource PIM catch-22 where principalId filtering required
        Microsoft.Authorization/roleAssignments/read at the scope).
 
        Two endpoints are used:
            * roleEligibilityScheduleInstances - eligible (PIM) assignments
            * roleAssignmentScheduleInstances - currently active assignments
              (both PIM-activated and permanent role assignments that ARM has
              materialized as schedule instances)
 
        Display names for role definitions, subscriptions, and management
        groups are resolved on demand through ARM REST so that Az.Resources
        is not required.
 
    .PARAMETER UserId
        UPN of the user. Used to (re)establish the Az context if necessary.
 
    .PARAMETER UserObjectId
        Azure AD object ID of the user. Used to classify Direct vs Inherited
        assignments returned by asTarget().
 
    .PARAMETER IncludeActive
        Include active (currently-assigned) Azure Resource roles.
 
    .PARAMETER IncludeEligible
        Include eligible (PIM) Azure Resource roles.
 
    .PARAMETER SubscriptionIds
        Optional client-side filter. When provided, results are limited to
        scopes under any of the supplied subscription IDs (or matching
        management-group / tenant scopes are kept as-is).
 
    .PARAMETER OnlyDirtyManagementGroups
        Reserved for delta-refresh callers. Currently a no-op for the
        root-scope endpoint because asTarget() already returns everything
        the user can see in one call.
 
    .PARAMETER DisableParallelProcessing
        Reserved for backward compatibility with prior implementations.
        Has no effect on the root-scope endpoint (single ARM call per
        endpoint, paginated).
 
    .PARAMETER ThrottleLimit
        Reserved for backward compatibility. Unused.
 
    .EXAMPLE
        Get-AzureResourceRoles -UserId 'user@contoso.com' -UserObjectId '...' -IncludeEligible -IncludeActive
 
    .NOTES
        Requires Az.Accounts (for Get-AzContext / Connect-AzAccount / token
        acquisition). Az.Resources is not required.
        ARM API versions:
            * Schedule instances : 2020-10-01
            * Role definitions : 2022-04-01
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$UserId,

        [Parameter(Mandatory = $true)]
        [string]$UserObjectId,

        [switch]$IncludeActive,
        [switch]$IncludeEligible,

        [string[]]$SubscriptionIds,
        [switch]$OnlyDirtyManagementGroups,
        [switch]$DisableParallelProcessing,
        [int]$ThrottleLimit = 10
    )

    Write-Verbose "Get-AzureResourceRoles: using ARM root-scope query with asTarget() for principal $UserObjectId"

    if (-not $IncludeActive -and -not $IncludeEligible) {
        Write-Verbose "Neither IncludeActive nor IncludeEligible specified - nothing to fetch"
        return @()
    }

    # OnlyDirtyManagementGroups is a no-op for the root-scope endpoint - asTarget()
    # already returns everything the principal can see across MGs in one call.
    if ($OnlyDirtyManagementGroups) {
        Write-Verbose "OnlyDirtyManagementGroups is a no-op for the ARM root-scope endpoint; returning empty"
        return @()
    }

    # ----- 1. Ensure Azure context --------------------------------------------------
    try {
        $azContext = Get-AzContext -ErrorAction SilentlyContinue
        if (-not $azContext) {
            Write-Verbose "No Az context - connecting as $UserId"
            Connect-AzAccount -AccountId $UserId -ErrorAction Stop | Out-Null
            $azContext = Get-AzContext -ErrorAction Stop
        }
        elseif ($azContext.Account.Id -ne $UserId -and $azContext.Account.Id -ne $UserObjectId) {
            Write-Verbose "Az context mismatch - reconnecting as $UserId"
            Connect-AzAccount -AccountId $UserId -ErrorAction Stop | Out-Null
            $azContext = Get-AzContext -ErrorAction Stop
        }
    }
    catch {
        Write-Warning "Failed to establish Azure context: $($_.Exception.Message)"
        return @()
    }

    # ----- 2. Acquire ARM bearer token ---------------------------------------------
    $armToken = $null
    $headers  = $null
    try {
        try {
            $tokenObj = Get-AzAccessToken -ResourceUrl 'https://management.azure.com/' -ErrorAction Stop
            if ($tokenObj.Token -is [System.Security.SecureString]) {
                $armToken = [System.Net.NetworkCredential]::new('', $tokenObj.Token).Password
            }
            elseif ($tokenObj.Token -is [string] -and $tokenObj.Token.Length -gt 0) {
                $armToken = $tokenObj.Token
            }
            else {
                $secureToken = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com/' -AsSecureString -ErrorAction Stop).Token
                $armToken    = [System.Net.NetworkCredential]::new('', $secureToken).Password
            }
            if ([string]::IsNullOrEmpty($armToken)) { throw 'Token was null or empty after extraction.' }
        }
        catch {
            Write-Warning "Failed to acquire ARM access token: $($_.Exception.Message)"
            return @()
        }

        $headers = @{
            'Authorization' = "Bearer $armToken"
            'Content-Type'  = 'application/json'
        }

        # ----- 3. Display-name caches ----------------------------------------------
        $roleDefCache = @{}
        $subNameCache = @{}
        $mgNameCache  = @{}

        # ----- 4. Helpers ----------------------------------------------------------

        # Paginate a single ARM list endpoint and return all items.
        $invokeArmList = {
            param([string]$Uri)
            $items   = [System.Collections.ArrayList]::new()
            $nextUri = $Uri
            while ($nextUri) {
                try {
                    $resp = Invoke-RestMethod -Uri $nextUri -Headers $headers -Method Get -ErrorAction Stop
                    if ($resp.value) {
                        foreach ($item in $resp.value) { [void]$items.Add($item) }
                    }
                    $nextUri = if ($resp.PSObject.Properties.Name -contains 'nextLink') { $resp.nextLink } else { $null }
                }
                catch {
                    Write-Verbose "ARM list failed ($nextUri): $($_.Exception.Message)"
                    break
                }
            }
            return ,$items
        }

        # Resolve a role definition (full ARM path or bare GUID) to its friendly name.
        $resolveRoleDefName = {
            param([string]$RoleDefId)
            if ([string]::IsNullOrEmpty($RoleDefId)) { return 'Unknown' }
            $guid = $RoleDefId
            if ($guid -match '/providers/Microsoft\.Authorization/roleDefinitions/([a-fA-F0-9\-]{36})') { $guid = $matches[1] }
            if ($roleDefCache.ContainsKey($guid)) { return $roleDefCache[$guid] }
            try {
                $rdPath = if ($RoleDefId.StartsWith('/')) { $RoleDefId } else { "/providers/Microsoft.Authorization/roleDefinitions/$guid" }
                $rdUri  = "https://management.azure.com$rdPath`?api-version=2022-04-01"
                $rdResp = Invoke-RestMethod -Uri $rdUri -Headers $headers -Method Get -ErrorAction SilentlyContinue
                $name   = if ($rdResp -and $rdResp.properties -and $rdResp.properties.roleName) { $rdResp.properties.roleName } else { "Unknown ($guid)" }
            }
            catch {
                $name = "Unknown ($guid)"
            }
            $roleDefCache[$guid] = $name
            return $name
        }

        # Resolve a subscription display name via ARM REST (does not require Az.Resources).
        $resolveSubName = {
            param([string]$SubId)
            if ([string]::IsNullOrEmpty($SubId)) { return $null }
            if ($subNameCache.ContainsKey($SubId)) { return $subNameCache[$SubId] }
            $name = $SubId
            try {
                $sUri  = "https://management.azure.com/subscriptions/$SubId`?api-version=2022-12-01"
                $sResp = Invoke-RestMethod -Uri $sUri -Headers $headers -Method Get -ErrorAction SilentlyContinue
                if ($sResp -and $sResp.displayName) { $name = $sResp.displayName }
            }
            catch { }
            $subNameCache[$SubId] = $name
            return $name
        }

        # Resolve a management-group display name via ARM REST.
        $resolveMgName = {
            param([string]$MgId)
            if ([string]::IsNullOrEmpty($MgId)) { return $null }
            if ($mgNameCache.ContainsKey($MgId)) { return $mgNameCache[$MgId] }
            $name = $MgId
            try {
                $mUri  = "https://management.azure.com/providers/Microsoft.Management/managementGroups/$MgId`?api-version=2020-05-01"
                $mResp = Invoke-RestMethod -Uri $mUri -Headers $headers -Method Get -ErrorAction SilentlyContinue
                if ($mResp -and $mResp.properties -and $mResp.properties.displayName) { $name = $mResp.properties.displayName }
            }
            catch { }
            $mgNameCache[$MgId] = $name
            return $name
        }

        # Resolve an ARM scope string to a structured info hashtable.
        $getScopeInfo = {
            param([string]$Scope)

            if ([string]::IsNullOrEmpty($Scope) -or $Scope -eq '/') {
                return @{
                    ScopeType        = 'Tenant'
                    ResourceDisplay  = '/'
                    SubscriptionId   = $null
                    SubscriptionName = $null
                    ScopeDisplayName = 'Tenant Root'
                }
            }
            if ($Scope -match '^/providers/Microsoft\.Management/managementGroups/([^/]+)$') {
                $mgId = $matches[1]
                $dn   = & $resolveMgName $mgId
                return @{
                    ScopeType        = 'Management Group'
                    ResourceDisplay  = $dn
                    SubscriptionId   = $null
                    SubscriptionName = $null
                    ScopeDisplayName = "MG: $dn"
                }
            }
            if ($Scope -match '^/subscriptions/([a-fA-F0-9\-]{36})/resourceGroups/([^/]+)/providers/.+$') {
                $subId   = $matches[1]
                $resName = ($Scope -split '/')[-1]
                $subName = & $resolveSubName $subId
                return @{
                    ScopeType        = 'Resource'
                    ResourceDisplay  = $resName
                    SubscriptionId   = $subId
                    SubscriptionName = $subName
                    ScopeDisplayName = "Resource: $resName"
                }
            }
            if ($Scope -match '^/subscriptions/([a-fA-F0-9\-]{36})/resourceGroups/([^/]+)$') {
                $subId   = $matches[1]
                $rgName  = $matches[2]
                $subName = & $resolveSubName $subId
                return @{
                    ScopeType        = 'Resource Group'
                    ResourceDisplay  = $rgName
                    SubscriptionId   = $subId
                    SubscriptionName = $subName
                    ScopeDisplayName = "RG: $rgName"
                }
            }
            if ($Scope -match '^/subscriptions/([a-fA-F0-9\-]{36})$') {
                $subId   = $matches[1]
                $subName = & $resolveSubName $subId
                return @{
                    ScopeType        = 'Subscription'
                    ResourceDisplay  = $subName
                    SubscriptionId   = $subId
                    SubscriptionName = $subName
                    ScopeDisplayName = "Sub: $subName"
                }
            }
            return @{
                ScopeType        = 'Unknown'
                ResourceDisplay  = $Scope
                SubscriptionId   = $null
                SubscriptionName = $null
                ScopeDisplayName = $Scope
            }
        }

        # ----- 5. Query root-scope endpoints with asTarget() -----------------------
        $apiVer  = '2020-10-01'
        $baseUri = 'https://management.azure.com/providers/Microsoft.Authorization'

        $eligibleRaw = @()
        $activeRaw   = @()

        if ($IncludeEligible) {
            $uri = "$baseUri/roleEligibilityScheduleInstances?api-version=$apiVer&`$filter=asTarget()"
            Write-Verbose "Querying eligible role schedule instances: $uri"
            $eligibleRaw = & $invokeArmList $uri
            Write-Verbose "asTarget() returned $($eligibleRaw.Count) eligible Azure Resource role instance(s)"
        }

        if ($IncludeActive) {
            $uri = "$baseUri/roleAssignmentScheduleInstances?api-version=$apiVer&`$filter=asTarget()"
            Write-Verbose "Querying active role schedule instances: $uri"
            $activeRaw = & $invokeArmList $uri
            Write-Verbose "asTarget() returned $($activeRaw.Count) active Azure Resource role instance(s)"
        }

        # ----- 6. Project raw items into role objects ------------------------------
        $allRoles = [System.Collections.ArrayList]::new()

        # Safe property accessor - StrictMode-friendly. Returns $null if the
        # property is missing instead of throwing.
        $getProp = {
            param($Object, [string]$Name)
            if ($null -eq $Object) { return $null }
            $p = $Object.PSObject.Properties[$Name]
            if ($p) { return $p.Value }
            return $null
        }

        $projectInstance = {
            param($Item, [string]$Status)

            $props = & $getProp $Item 'properties'
            if (-not $props) { return $null }

            $scope        = & $getProp $props 'scope'
            $roleDefId    = & $getProp $props 'roleDefinitionId'
            $principalId  = & $getProp $props 'principalId'
            $principalTyp = & $getProp $props 'principalType'
            $startDt      = & $getProp $props 'startDateTime'
            $endDt        = & $getProp $props 'endDateTime'
            $assignType   = & $getProp $props 'assignmentType'   # 'Activated' or 'Assigned' (active endpoint)
            $memberTypeAp = & $getProp $props 'memberType'       # 'Direct' / 'Inherited' / 'Group'

            $scopeInfo = & $getScopeInfo $scope
            $roleName  = & $resolveRoleDefName $roleDefId

            # Direct vs Inherited - prefer ARM-supplied memberType when present,
            # otherwise derive from principal identity vs caller.
            $memberType = if ($memberTypeAp) {
                $memberTypeAp
            }
            elseif ($principalTyp -eq 'Group') {
                'Group'
            }
            elseif ($principalId -and $principalId -ne $UserObjectId) {
                'Inherited'
            }
            else {
                'Direct'
            }

            $formatted = if ($scopeInfo.ScopeDisplayName) { $scopeInfo.ScopeDisplayName } else { $scope }

            [PSCustomObject]@{
                RoleId               = $Item.name
                RoleDefinitionId     = $roleDefId
                DisplayName          = $roleName
                ResourceName         = $scopeInfo.ResourceDisplay
                ResourceDisplayName  = $scopeInfo.ResourceDisplay
                ScopeDisplayName     = $scopeInfo.ScopeDisplayName
                Type                 = 'AzureResource'
                Status               = $Status
                MemberType           = $memberType
                SubscriptionId       = $scopeInfo.SubscriptionId
                SubscriptionName     = $scopeInfo.SubscriptionName
                FullScope            = $scope
                ObjectId             = $principalId
                ObjectType           = $principalTyp
                StartDateTime        = $startDt
                EndDateTime          = $endDt
                Scope                = $scopeInfo.ScopeType
                FormattedScope       = $formatted
                AssignmentType       = $assignType
                # Used by reduced-scope Azure activation. For eligible instances ARM
                # returns the parent eligibility schedule ID in roleEligibilityScheduleId.
                LinkedRoleEligibilityScheduleId = (& $getProp $props 'roleEligibilityScheduleId')
            }
        }

        foreach ($item in $eligibleRaw) {
            $obj = & $projectInstance $item 'Eligible'
            if ($obj) { [void]$allRoles.Add($obj) }
        }
        foreach ($item in $activeRaw) {
            $obj = & $projectInstance $item 'Active'
            if ($obj) { [void]$allRoles.Add($obj) }
        }

        Write-Verbose "Projected $($allRoles.Count) Azure Resource role object(s) from ARM root-scope query"

        # ----- 7. Optional client-side subscription filter -------------------------
        if ($SubscriptionIds -and $SubscriptionIds.Count -gt 0) {
            $subFilter = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($s in $SubscriptionIds) { if ($s) { [void]$subFilter.Add($s) } }
            $filtered = [System.Collections.ArrayList]::new()
            foreach ($r in $allRoles) {
                # Keep roles with no subscription (tenant / MG scopes) plus those in the filter set.
                if (-not $r.SubscriptionId -or $subFilter.Contains($r.SubscriptionId)) {
                    [void]$filtered.Add($r)
                }
            }
            Write-Verbose "Filtered by SubscriptionIds: kept $($filtered.Count) of $($allRoles.Count) role(s)"
            $allRoles = $filtered
        }

        # ----- 8. Deduplicate ------------------------------------------------------
        $uniqueRoles = [System.Collections.ArrayList]::new()
        $seen = [System.Collections.Generic.HashSet[string]]::new()
        foreach ($r in $allRoles) {
            $defGuid = $r.RoleDefinitionId
            if ($defGuid -and $defGuid -match '/providers/Microsoft\.Authorization/roleDefinitions/([a-fA-F0-9\-]{36})') { $defGuid = $matches[1] }
            $key = "{0}|{1}|{2}|{3}" -f $defGuid, $r.FullScope, $r.Status, $r.ObjectId
            if ($seen.Add($key)) { [void]$uniqueRoles.Add($r) }
        }

        $activeCount   = @($uniqueRoles | Where-Object { $_.Status -eq 'Active' }).Count
        $eligibleCount = @($uniqueRoles | Where-Object { $_.Status -eq 'Eligible' }).Count
        Write-Verbose "Azure Resource roles after dedupe: $($uniqueRoles.Count) total ($eligibleCount eligible, $activeCount active)"

        # Return as concrete array
        return ,@($uniqueRoles)
    }
    catch {
        Write-Warning "Failed to retrieve Azure Resource roles: $($_.Exception.Message)"
        return @()
    }
    finally {
        # Best-effort scrub of plaintext bearer token from memory.
        if (Get-Variable -Name 'armToken' -Scope 0 -ErrorAction SilentlyContinue) { $armToken = $null }
        if (Get-Variable -Name 'headers'  -Scope 0 -ErrorAction SilentlyContinue) { $headers  = $null }
    }
}