Public/Get-AACRoleAssignment.ps1

function Get-AACRoleAssignment {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Lists every Azure role assignment - root, management groups,
        subscriptions, resource groups and resources - with who holds it and
        what they are, what the role can do, how far it reaches, whether it
        is standing or PIM, the groups' members, who created it - and flags
        orphaned assignments, risky grants and custom roles that are unused
        or as powerful as Owner.
    .DESCRIPTION
        The inventory under the access review: where Get-AACAccessReview
        says what to fix, this lists everything as it is. Reads, read-only:
          Resource Graph every role assignment and definition (tenant-
                           wide, so management group and root assignments
                           that reach the scope count), the management
                           groups, the user-assigned identities and the
                           roles policies use
          PIM each subscription's eligible assignments and
                           which active ones were activated just in time
          Microsoft Graph the principals and the assignments' creators;
                           with -ExpandGroup, each group's members
                           (transitive)
 
        Each assignment (AAC.RoleAssignment):
          Principal name, sign-in name, and its type: User, Guest,
                           Group, Application, Application (external - another
                           tenant's), Managed identity (system- or
                           user-assigned), or Deleted
          Role name; Built-in or Custom; Privileged (Owner,
                           Contributor, User Access Administrator, RBAC
                           Administrator, or a custom role with every action
                           or able to grant access); CanWriteRoleAssignments
          Scope type (Root, Management group, Subscription,
                           Resource group, Resource), name, and Reach - Direct,
                           or "Inherited by N subscription(s)"
          Assignment Permanent, Activated (PIM) or Eligible (PIM)
          More Members and MemberNames (groups, with
                           -ExpandGroup), Condition (ABAC), CreatedOn,
                           CreatedBy, EndsOn (eligible)
          Findings and the worst one's Severity: High for a deleted
                           principal with a privileged role, a guest with
                           one, an application as Owner on a subscription or
                           above, a custom role equivalent to Owner; Medium for
                           orphaned assignments and Owner or User Access
                           Administrator given to a user directly; Low for
                           standing privileged access a person could hold
                           eligible
        The report also lists the custom roles (AAC.CustomRole): their
        assignments, whether a policy uses them, unused, equivalent to
        Owner, able to grant access.
 
        Reader on the scope; for Entra ID Directory.Read.All (GroupMember.
        Read.All for -ExpandGroup). What Graph or PIM refuses is left out,
        and said. -SkipEntra reads Azure only.
    .PARAMETER SubscriptionId
        Only these subscriptions (and the management group and root
        assignments that reach them).
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER PrincipalType
        Only these principals: User, Guest, Group, Application,
        ManagedIdentity, Deleted.
    .PARAMETER RoleName
        Only these roles; wildcards work ('*Owner*').
    .PARAMETER ScopeType
        Only assignments at these scopes: Root, ManagementGroup,
        Subscription, ResourceGroup, Resource.
    .PARAMETER Privileged
        Only privileged roles.
    .PARAMETER WithFinding
        Only assignments with a finding.
    .PARAMETER ExpandGroup
        Read each group's members (transitive, up to 200 groups).
    .PARAMETER IncludeCustomRole
        Also return the custom roles (AAC.CustomRole), after the
        assignments.
    .PARAMETER SkipPim
        Don't read PIM (eligible and activated assignments).
    .PARAMETER SkipEntra
        Don't read Entra ID: principals are shown by ID and type.
    .PARAMETER CsvPath
        Write the assignments to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report (assignments and custom roles).
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the assignments.
    .PARAMETER NoDisplay
        Return the assignments without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACRoleAssignment
        Every role assignment the account can see, those with findings first.
    .EXAMPLE
        Get-AACRoleAssignment -SubscriptionId $sub -ExpandGroup -HtmlPath .\Rbac.html
        One subscription's assignments - including those inherited from management groups - with each group's members.
    .EXAMPLE
        Get-AACRoleAssignment -PrincipalType Guest, Deleted -Privileged -NoDisplay | Select-Object Principal, Role, Scope
        Privileged guests and orphaned privileged assignments.
    .EXAMPLE
        Get-AACRoleAssignment -RoleName 'Owner' -ScopeType ManagementGroup, Subscription -CsvPath .\Owners.csv
        Who is Owner at the top of the hierarchy, for review.
    .OUTPUTS
        AAC.RoleAssignment (and AAC.CustomRole, with -IncludeCustomRole)
    #>

    [CmdletBinding()]
    [OutputType('AAC.RoleAssignment')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [ValidateSet('User', 'Guest', 'Group', 'Application', 'ManagedIdentity', 'Deleted')]
        [string[]] $PrincipalType,

        [SupportsWildcards()]
        [string[]] $RoleName,

        [ValidateSet('Root', 'ManagementGroup', 'Subscription', 'ResourceGroup', 'Resource')]
        [string[]] $ScopeType,

        [switch] $Privileged,

        [switch] $WithFinding,

        [switch] $ExpandGroup,

        [switch] $IncludeCustomRole,

        [switch] $SkipPim,

        [switch] $SkipEntra,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Azure role assignments',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ })
        ExpandGroup = [bool]$ExpandGroup; Pim = -not $SkipPim; Entra = -not $SkipEntra; TenantId = [string]$(if ($script:AACSession) { $script:AACSession.TenantId })
    }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Role assignments' -Color 'deepskyblue3_1' }
    $outcome = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"

        $query = Get-AACRoleAssignmentQuery
        Update-AACProgress -Id 'read' -Total $query.Count -Description 'Reading the role assignments and definitions'
        $read = Invoke-AACGraphBatch -Query $query -SubscriptionId $scope.GraphScope -AllowFailure @('managementGroups', 'userIdentities', 'policyRoles') -OnProgress {
            param($QueryName, $Done, $Total)
            Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $QueryName ($Done of $Total)"
        }
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} role assignment(s) and {1:N0} role definition(s)' -f @($read.Rows['roleAssignments']).Count, @($read.Rows['roleDefinitions']).Count)

        # PIM: eligible assignments, and which active ones were activated.
        $pim = $null
        if ($request.Pim) {
            $uris = [ordered]@{}
            foreach ($id in $scope.Ids) {
                $uris["eligible|$id"] = "/subscriptions/$id/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01"
                $uris["active|$id"] = "/subscriptions/$id/providers/Microsoft.Authorization/roleAssignmentScheduleInstances?api-version=2020-10-01"
            }
            Update-AACProgress -Id 'pim' -Total ([Math]::Max(1, $uris.Count)) -Description "Reading PIM for $($scope.Ids.Count) subscription(s)"
            $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($PimDone, $PimTotal) Update-AACProgress -Id 'pim' -Increment 1 }
            $pim = @{ Eligible = [System.Collections.Generic.List[object]]::new(); Instances = [System.Collections.Generic.List[object]]::new(); Errors = [System.Collections.Generic.List[string]]::new(); Read = $false }
            foreach ($key in $uris.Keys) {
                $kind, $id = $key.Split('|')
                $answer = $answers[$uris[$key]]
                if (-not $answer -or $answer.Error) { $pim.Errors.Add("$($scope.Names[$id.ToLowerInvariant()]) ($kind): $(if ($answer) { $answer.Error } else { 'no answer' })"); continue }
                $pim.Read = $true
                foreach ($i in @($answer.Items | Where-Object { $null -ne $_ })) { if ($kind -eq 'eligible') { $pim.Eligible.Add($i) } else { $pim.Instances.Add($i) } }
            }
            Update-AACProgress -Id 'pim' -Complete -Description ('Read {0:N0} eligible assignment(s){1}' -f $pim.Eligible.Count, $(if ($pim.Errors.Count) { "; $($pim.Errors.Count) read(s) refused" }))
        }

        # Entra ID: the principals and creators, and the groups' members.
        $directory = $null
        $members = $null
        if ($request.Entra) {
            $ids = @(@($read.Rows['roleAssignments'] | Where-Object { $_ } | ForEach-Object { [string]$_['principalId']; [string]$_['createdBy'] }) + @(if ($pim) { $pim.Eligible | ForEach-Object { [string]$_['properties']['principalId'] } }) | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique)
            Update-AACProgress -Id 'principals' -Indeterminate -Description "Looking up $($ids.Count) principal(s) in Entra ID"
            $directory = Get-AACDirectoryObject -Id $ids -Property 'appOwnerOrganizationId'
            Update-AACProgress -Id 'principals' -Complete -Description ('Looked up {0:N0} principal(s){1}' -f $directory.Objects.Count, $(if ($directory.Error) { " - Graph refused: $($directory.Error)" }))
            if ($request.ExpandGroup -and -not $directory.Error) {
                $groups = @($directory.Objects.Keys | Where-Object { [string]$directory.Objects[$_]['@odata.type'] -like '*group' } | Select-Object -First 200)
                if ($groups.Count) {
                    $q = [ordered]@{}
                    foreach ($g in $groups) { $q[$g] = "https://graph.microsoft.com/v1.0/groups/$g/transitiveMembers?`$select=id,displayName,userPrincipalName&`$top=999" }
                    Update-AACProgress -Id 'members' -Total $q.Count -Description "Reading the members of $($q.Count) group(s)"
                    $answer = Read-AACGraphQuery -Query $q -OnDone { param($Key, $Failure, $GraphDone, $GraphTotal) Update-AACProgress -Id 'members' -Increment 1 }
                    $members = @{}
                    foreach ($g in $groups) {
                        if ($answer.Errors.Contains($g)) { $members[$g] = @{ Error = [string]$answer.Errors[$g] }; continue }
                        $people = @(@($answer.Data[$g]) | Where-Object { $_ })
                        $names = @($people | Select-Object -First 10 | ForEach-Object { if ($_['userPrincipalName']) { [string]$_['userPrincipalName'] } else { [string]$_['displayName'] } })
                        $members[$g] = @{ Count = $people.Count; Names = ($names -join ', ') + $(if ($people.Count -gt 10) { ", +$($people.Count - 10) more" } else { '' }) }
                    }
                    Update-AACProgress -Id 'members' -Complete -Description "Read the members of $($groups.Count) group(s)"
                }
            }
        }
        $result = ConvertTo-AACRoleAssignment -Read $read -Scope $scope -Pim $pim -Directory $directory -Members $members -TenantId $request.TenantId
        @{ Result = $result; Scope = $scope }
    }

    $assignments = @($outcome.Result.Assignments)
    if ($PrincipalType) {
        $types = @{ User = @('User'); Guest = @('Guest'); Group = @('Group', 'Group (foreign)'); Application = @('Application', 'Application (external)', 'Service principal'); ManagedIdentity = @('Managed identity (system-assigned)', 'Managed identity (user-assigned)'); Deleted = @('Deleted') }
        $wanted = @($PrincipalType | ForEach-Object { $types[$_] })
        $assignments = @($assignments | Where-Object { $wanted -contains $_.PrincipalType })
    }
    if ($RoleName) { $assignments = @($assignments | Where-Object { $role = $_.Role; @($RoleName | Where-Object { $role -like $_ }).Count }) }
    if ($ScopeType) { $scopes = @{ Root = 'Root'; ManagementGroup = 'Management group'; Subscription = 'Subscription'; ResourceGroup = 'Resource group'; Resource = 'Resource' }; $wantedScopes = @($ScopeType | ForEach-Object { $scopes[$_] }); $assignments = @($assignments | Where-Object { $wantedScopes -contains $_.ScopeType }) }
    if ($Privileged) { $assignments = @($assignments | Where-Object Privileged) }
    if ($WithFinding) { $assignments = @($assignments | Where-Object Findings) }

    $stats = $outcome.Result.Stats
    $customRoles = @($outcome.Result.CustomRoles)
    $rank = Get-AACSeverityRank
    $high = @($assignments | Where-Object Severity -EQ 'High').Count
    $findingCount = @($assignments | Where-Object Findings).Count
    $typeTones = @{ User = 'info'; Guest = 'warn'; Group = 'good'; Application = 'violet'; 'Application (external)' = 'bad'; 'Managed identity (system-assigned)' = 'neutral'; 'Managed identity (user-assigned)' = 'neutral'; Deleted = 'bad' }
    $kindTones = @{ Permanent = 'warn'; 'Activated (PIM)' = 'good'; 'Eligible (PIM)' = 'good' }
    $report = @{
        Subtitle = 'Role assignments: every Azure RBAC assignment at every scope - who, what, how far it reaches, and what is risky'
        Facts    = [ordered]@{ Scope = $outcome.Scope.Label; PIM = $(if ($SkipPim) { 'not read (-SkipPim)' } else { 'read' }); 'Entra ID' = $(if ($SkipEntra) { 'not read (-SkipEntra)' } else { 'read' }); Groups = $(if ($ExpandGroup) { 'members expanded' } else { 'not expanded (-ExpandGroup)' }) }
        Status   = $(if ($high) { 'Failed' } elseif ($findingCount) { 'Warning' } else { 'Success' })
        Headline = "$($assignments.Count) role assignment(s): $(@($assignments | Where-Object Privileged).Count) privileged, $(@($assignments | Where-Object Assignment -EQ 'Eligible (PIM)').Count) eligible in PIM, $findingCount with a finding ($high high)$(if ($stats.UnusedRoles -or $stats.OwnerRoles) { "; $($stats.UnusedRoles) unused custom role(s), $($stats.OwnerRoles) equivalent to Owner" })"
        Tiles    = @(
            @{ Value = '{0:N0}' -f $assignments.Count; Label = 'role assignments'; Tone = 'info' }
            @{ Value = '{0:N0}' -f @($assignments | Where-Object Privileged).Count; Label = 'privileged'; Tone = 'warn'; Table = 'assignments'; Filters = @{ Privileged = 'True' } }
            @{ Value = '{0:N0}' -f @($assignments | Where-Object Assignment -EQ 'Eligible (PIM)').Count; Label = 'eligible (PIM)'; Tone = 'good'; Table = 'assignments'; Filters = @{ Assignment = 'Eligible (PIM)' } }
            @{ Value = '{0:N0}' -f @($assignments | Where-Object { $_.PrincipalType -eq 'Deleted' -or $_.RoleType -eq 'Unknown' }).Count; Label = 'orphaned'; Tone = $(if ($stats.Orphaned) { 'bad' } else { 'good' }); Table = 'assignments'; Filters = @{ PrincipalType = 'Deleted' } }
            @{ Value = '{0:N0}' -f $high; Label = 'high findings'; Tone = $(if ($high) { 'bad' } else { 'good' }); Table = 'assignments'; Filters = @{ Severity = 'High' } }
            @{ Value = '{0:N0}' -f $customRoles.Count; Label = "custom roles ($($stats.UnusedRoles) unused)"; Tone = $(if ($stats.OwnerRoles) { 'bad' } elseif ($stats.UnusedRoles) { 'warn' } else { 'info' }); Table = 'customRoles' }
        )
        Notices  = @($outcome.Result.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'By principal type'; Kind = 'donut'; CenterLabel = 'assignments'; Items = @($assignments | Group-Object PrincipalType | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $typeTones[$_.Name]; Filter = $_.Name } }); Table = 'assignments'; Column = 'PrincipalType'; Console = $true }
            @{ Title = 'By scope'; Items = @(foreach ($s in 'Root', 'Management group', 'Subscription', 'Resource group', 'Resource') { $n = @($assignments | Where-Object ScopeType -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Filter = $s } } }); Table = 'assignments'; Column = 'ScopeType'; Tone = 'info' }
            @{ Title = 'Most assigned roles'; Items = @($assignments | Group-Object Role | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'assignments'; Column = 'Role'; Tone = 'violet' }
        )
        Tables   = @(
            @{ Id = 'assignments'; Title = 'Role assignments'; Section = 'Assignments'; Rows = $assignments; Noun = 'assignments'; GroupBy = @('ScopeType', 'Role', 'PrincipalType'); ConsoleLimit = 30
                Empty = 'No role assignment matches.'; EmptyStatus = 'Info'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Principal'; Label = 'Principal'; Console = $true; Pdf = $true }
                    @{ Key = 'PrincipalType'; Label = 'Type'; Type = 'badge'; Tones = $typeTones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Role'; Label = 'Role'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'ScopeType'; Label = 'Scope type'; Facet = $true; Console = $true }
                    @{ Key = 'Scope'; Label = 'Scope'; Console = $true; Pdf = $true }
                    @{ Key = 'Assignment'; Label = 'Assignment'; Type = 'badge'; Tones = $kindTones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Reach'; Label = 'Reach' }
                    @{ Key = 'Findings'; Label = 'Findings'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'RoleType'; Label = 'Role type'; Facet = $true }
                    @{ Key = 'Privileged'; Label = 'Privileged'; Facet = $true }
                    @{ Key = 'CanWriteRoleAssignments'; Label = 'Can grant access'; Facet = $true }
                    @{ Key = 'Members'; Label = 'Members'; Type = 'number' }
                    @{ Key = 'MemberNames'; Label = 'Member names'; Type = 'wide' }
                    @{ Key = 'SignInName'; Label = 'Sign-in name' }
                    @{ Key = 'Condition'; Label = 'Condition' }
                    @{ Key = 'CreatedOn'; Label = 'Created'; Type = 'date' }
                    @{ Key = 'CreatedBy'; Label = 'Created by' }
                    @{ Key = 'EndsOn'; Label = 'Eligible until'; Type = 'date' }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                    @{ Key = 'PrincipalId'; Label = 'Principal ID'; Type = 'mono' }
                    @{ Key = 'ScopeId'; Label = 'Scope ID'; Type = 'mono' }
                ) }
            @{ Id = 'customRoles'; Title = 'Custom roles'; Section = 'Custom roles'; Rows = $customRoles; Noun = 'roles'; ConsoleLimit = 15
                Columns = @(
                    @{ Key = 'Role'; Label = 'Role'; Console = $true; Pdf = $true }
                    @{ Key = 'Assignments'; Label = 'Assignments'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'Unused'; Label = 'Unused'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'OwnerEquivalent'; Label = 'Equivalent to Owner'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'CanWriteRoleAssignments'; Label = 'Can grant access'; Facet = $true; Console = $true }
                    @{ Key = 'UsedByPolicy'; Label = 'Used by a policy'; Facet = $true }
                    @{ Key = 'Actions'; Label = 'Actions'; Type = 'number' }
                    @{ Key = 'AssignableScopes'; Label = 'Assignable scopes'; Type = 'wide'; Pdf = $true }
                ) }
        )
        Hint     = '-PrincipalType, -RoleName, -ScopeType, -Privileged and -WithFinding narrow it; -ExpandGroup reads groups'' members; -NoDisplay returns the assignments; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $assignments -Noun 'assignment' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object (@($assignments) + @(if ($IncludeCustomRole) { $customRoles })) -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}