Public/Get-AACAccessReview.ps1

function Get-AACAccessReview {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        A least-privilege access review of Azure and Entra ID: every Azure
        RBAC assignment (permanent, PIM-activated and eligible), classic
        administrator, Entra ID directory role and Microsoft Graph
        application permission - with who uses their access, what's too
        broad or standing, and a recommendation for each - ready to attest.
    .DESCRIPTION
        Reads, read-only:
          Azure RBAC every role assignment and definition (Resource
                             Graph, tenant-wide - so management group and
                             root assignments that reach the scope count)
          PIM each subscription's eligible assignments and
                             which active ones were activated just in time
          Classic admins each subscription's (co-)administrators
          Activity each subscription's Activity Log for the last
                             -ActivityDays (30): who made changes, so unused
                             write access shows
          Entra ID the principals (users, guests, groups, service
                             principals, managed identities - and the deleted
                             ones), privileged users' last sign-in, the
                             directory roles (active, activated, eligible),
                             and the application permissions granted on
                             Microsoft Graph
        One row per assignment (AAC.AccessAssignment), each with its
        findings - standing privileged access that should be just-in-time,
        privileged guests, applications that can grant access or take over
        the tenant, disabled and dormant accounts, orphaned assignments,
        unused write access, wildcard custom roles, classic co-admins, too
        many Global Administrators, roles given to users directly - the
        worst finding's severity, a recommendation - Remove, Make eligible
        (PIM), Narrow the scope or role, Review usage, Use a group, or
        Keep - and the Action: what to do, in so many words.
 
        Service principals and managed identities are judged as the
        workloads they are: their elevated access is as much a risk as a
        person's (a leaked secret or a compromised workload acts with it,
        no MFA asked), but PIM needs a person to activate a role, so they
        are never told to be made eligible. They are told how to narrow
        the role and scope - Role Based Access Control Administrator with a
        condition in place of Owner, resource groups in place of the
        subscription - and how to protect the credential. Write access they
        haven't used in -ActivityDays is "Review usage", not "Remove": a
        monthly job or a disaster-recovery pipeline is quiet for weeks.
 
        For an attestation: -CsvPath writes every row with empty Decision
        and Reviewer columns to fill in and sign off; -HtmlPath and -PdfPath
        write the review as a report.
 
        Needs Reader on the scope; Microsoft Graph Directory.Read.All,
        RoleManagement.Read.Directory, Application.Read.All and (for last
        sign-ins) AuditLog.Read.All - what Graph refuses is left out, and
        said. -SkipEntra reads Azure only.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER ActivityDays
        How many days of Activity Log to read for unused access (0 to 90; 30
        by default; 0 skips it).
    .PARAMETER SkipEntra
        Don't read Entra ID roles and Graph application permissions (Azure
        RBAC is still reviewed; principals are still looked up).
    .PARAMETER Severity
        Only rows of these severities.
    .PARAMETER PrivilegedOnly
        Only privileged access (Owner, Contributor, User Access
        Administrator, RBAC Administrator, equivalent custom roles,
        privileged Entra roles and Graph permissions).
    .PARAMETER CsvPath
        Write every row to this CSV file - with Decision and Reviewer
        columns for the sign-off.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the rows.
    .PARAMETER NoDisplay
        Return the rows without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACAccessReview -PrivilegedOnly
        Who holds privileged access, how, and what to change.
    .EXAMPLE
        Get-AACAccessReview -ManagementGroupId 'mg-corp' -CsvPath .\out\AccessReview.csv -HtmlPath .\out\AccessReview.html
        An attestation file and report for a management group.
    .EXAMPLE
        Get-AACAccessReview -NoDisplay | Where-Object Recommendation -EQ 'Make eligible (PIM)' | Select-Object Principal, Role, Scope
        The standing access to move into PIM.
    .OUTPUTS
        AAC.AccessAssignment
    #>

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

        [string[]] $ManagementGroupId,

        [ValidateRange(0, 90)]
        [int] $ActivityDays = 30,

        [switch] $SkipEntra,

        [ValidateSet('Critical', 'High', 'Medium', 'Low', 'Info')]
        [string[]] $Severity,

        [switch] $PrivilegedOnly,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Azure access review',

        [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 { $_ }); ActivityDays = $ActivityDays; SkipEntra = [bool]$SkipEntra }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Access review' -Color 'deepskyblue3_1' }
    $state = 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)"

        # --- Azure RBAC, tenant-wide --------------------------------------------------------------------------------
        Update-AACProgress -Id 'rbac' -Indeterminate -Description 'Reading the role assignments and definitions'
        $rbac = Invoke-AACGraphBatch -Query ([ordered]@{
                roleAssignments = @{ Tenant = $true; Query = "authorizationresources | where type =~ 'microsoft.authorization/roleassignments' | project id, principalId = tostring(properties.principalId), principalType = tostring(properties.principalType), roleId = tolower(tostring(properties.roleDefinitionId)), scope = tolower(tostring(properties.scope)), createdOn = tostring(properties.createdOn)" }
                roleDefinitions = @{ Tenant = $true; Query = "authorizationresources | where type =~ 'microsoft.authorization/roledefinitions' | project id = tolower(id), roleName = tostring(properties.roleName), roleType = tostring(properties.type), permissions = properties.permissions" }
            })
        $assignments = @($rbac.Rows['roleAssignments'] | Where-Object { $null -ne $_ })
        Update-AACProgress -Id 'rbac' -Complete -Description ('Read {0:N0} role assignment(s) and {1:N0} role definition(s)' -f $assignments.Count, @($rbac.Rows['roleDefinitions']).Count)

        # --- Per subscription: PIM, classic admins, activity -------------------------------------------------------------
        $uris = [ordered]@{}
        $now = [datetime]::UtcNow
        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"
            $uris["classic|$id"] = "/subscriptions/$id/providers/Microsoft.Authorization/classicAdministrators?api-version=2015-07-01"
            if ($request.ActivityDays) {
                $filter = [System.Uri]::EscapeDataString("eventTimestamp ge '$($now.AddDays(-$request.ActivityDays).ToString('o'))' and eventTimestamp le '$($now.ToString('o'))'")
                $uris["activity|$id"] = "/subscriptions/$id/providers/Microsoft.Insights/eventtypes/management/values?api-version=2015-04-01&`$filter=$filter&`$select=caller,eventTimestamp,status,authorization"
            }
        }
        Update-AACProgress -Id 'arm' -Total ([Math]::Max(1, $uris.Count)) -Description "Reading PIM, classic administrators$(if ($request.ActivityDays) { " and $($request.ActivityDays) days of Activity Log" }) for $($scope.Ids.Count) subscription(s)"
        $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($ArmDone, $ArmTotal) Update-AACProgress -Id 'arm' -Increment 1 }
        $eligible = [System.Collections.Generic.List[object]]::new()
        $instances = [System.Collections.Generic.List[object]]::new()
        $classic = [System.Collections.Generic.List[object]]::new()
        $activity = if ($request.ActivityDays) { @{} } else { $null }
        $unreadArm = [System.Collections.Generic.List[string]]::new()
        foreach ($key in $uris.Keys) {
            $kind, $id = $key.Split('|')
            $answer = $answers[$uris[$key]]
            if (-not $answer -or $answer.Error) { if ($kind -ne 'classic') { $unreadArm.Add("$kind for $($scope.Names[$id.ToLowerInvariant()]): $(if ($answer) { $answer.Error } else { 'no answer' })") }; continue }
            $items = @($answer.Items | Where-Object { $null -ne $_ })
            switch ($kind) {
                'eligible' { foreach ($i in $items) { $eligible.Add($i) } }
                'active' { foreach ($i in $items) { $instances.Add($i) } }
                'classic' { $classic.Add(@{ SubscriptionId = $id; Items = $items }) }
                'activity' {
                    foreach ($e in $items) {
                        $action = [string]$(if ($e['authorization'] -is [System.Collections.IDictionary]) { $e['authorization']['action'] })
                        if ($action -notmatch '/(write|delete|action)$') { continue }
                        $status = if ($e['status'] -is [System.Collections.IDictionary]) { [string]$e['status']['value'] } else { [string]$e['status'] }
                        if ($status -and $status -notin 'Succeeded', 'Started', 'Accepted') { continue }
                        $caller = ([string]$e['caller']).ToLowerInvariant()
                        $when = [datetime]::MinValue
                        if ($caller -and [datetime]::TryParse([string]$e['eventTimestamp'], [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$when)) {
                            if (-not $activity.Contains($caller) -or $activity[$caller] -lt $when) { $activity[$caller] = $when }
                        }
                    }
                }
            }
        }
        Update-AACProgress -Id 'arm' -Complete -Description ('Read {0:N0} eligible assignment(s){1}{2}' -f $eligible.Count, $(if ($null -ne $activity) { ", $($activity.Count) caller(s) with changes" }), $(if ($unreadArm.Count) { "; $($unreadArm.Count) read(s) failed" }))

        # --- Entra ID ------------------------------------------------------------------------------------------------------
        $graph = @{ Data = @{}; Errors = @{} }
        if (-not $request.SkipEntra) {
            Update-AACProgress -Id 'entra' -Indeterminate -Description 'Reading the Entra ID roles and the Microsoft Graph application permissions'
            $graphUri = 'https://graph.microsoft.com/v1.0'
            $graph = Read-AACGraphQuery -Query ([ordered]@{
                    entraAssignments = "$graphUri/roleManagement/directory/roleAssignments?`$select=id,principalId,roleDefinitionId,directoryScopeId"
                    entraEligibility = "$graphUri/roleManagement/directory/roleEligibilitySchedules?`$select=id,principalId,roleDefinitionId"
                    entraActive      = "$graphUri/roleManagement/directory/roleAssignmentScheduleInstances?`$select=principalId,roleDefinitionId,assignmentType"
                    entraDefinitions = "$graphUri/roleManagement/directory/roleDefinitions?`$select=id,displayName,templateId"
                    graphApp         = "$graphUri/servicePrincipals(appId='00000003-0000-0000-c000-000000000000')?`$select=id,appRoles"
                    graphGrants      = "$graphUri/servicePrincipals(appId='00000003-0000-0000-c000-000000000000')/appRoleAssignedTo?`$top=999"
                })
            Update-AACProgress -Id 'entra' -Complete -Description ('Read {0:N0} Entra ID role assignment(s) and {1:N0} Graph application permission(s)' -f @($graph.Data['entraAssignments']).Count, @($graph.Data['graphGrants']).Count)
        }
        $principalIds = @(@($assignments | ForEach-Object { [string]$_['principalId'] }) + @($eligible | ForEach-Object { [string]$_['properties']['principalId'] }) + @($graph.Data['entraAssignments'] + $graph.Data['entraEligibility'] + $graph.Data['graphGrants'] | Where-Object { $_ } | ForEach-Object { [string]$_['principalId'] }) | Where-Object { $_ } | Select-Object -Unique)
        Update-AACProgress -Id 'principals' -Indeterminate -Description "Looking up $($principalIds.Count) principal(s) in Entra ID"
        $directory = Get-AACDirectoryObject -Id $principalIds
        # The privileged users' last sign-in (needs AuditLog.Read.All and Entra ID P1).
        $privilegedNames = 'Owner', 'Contributor', 'User Access Administrator', 'Role Based Access Control Administrator'
        $roleNames = @{}
        foreach ($d in @($rbac.Rows['roleDefinitions'])) { if ($d) { $roleNames[([string]$d['id'] -replace '^.*/', '')] = [string]$d['roleName'] } }
        $users = @($assignments | Where-Object { $roleNames[([string]$_['roleId'] -replace '^.*/', '')] -in $privilegedNames } | ForEach-Object { ([string]$_['principalId']).ToLowerInvariant() } | Where-Object { $directory.Objects.Contains($_) -and [string]$directory.Objects[$_]['@odata.type'] -like '*user' } | Select-Object -Unique -First 300)
        $signIns = @{}
        $signInError = ''
        if ($users.Count -and -not $directory.Error) {
            $signInQuery = [ordered]@{}
            foreach ($u in $users) { $signInQuery[$u] = "https://graph.microsoft.com/v1.0/users/$($u)?`$select=id,signInActivity" }
            $signInRead = Read-AACGraphQuery -Query $signInQuery
            foreach ($u in $users) {
                $last = if ($signInRead.Data.Contains($u) -and $signInRead.Data[$u]['signInActivity']) { [string]$signInRead.Data[$u]['signInActivity']['lastSignInDateTime'] } else { '' }
                $when = [datetime]::MinValue
                if ($last -and [datetime]::TryParse($last, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$when)) { $signIns[$u] = $when }
            }
            $signInError = @($signInRead.Errors.Values | Where-Object { $_ }) | Select-Object -First 1
        }
        Update-AACProgress -Id 'principals' -Complete -Description ('Looked up {0:N0} principal(s){1}' -f $directory.Objects.Count, $(if ($directory.Error) { " - Graph refused: $($directory.Error)" }))

        Update-AACProgress -Id 'review' -Indeterminate -Description 'Reviewing every assignment'
        $chain = @{}
        foreach ($s in $scope.Subscriptions) { $chain[([string]$s['subscriptionId']).ToLowerInvariant()] = @(@($s['chain']) | Where-Object { $_ -is [System.Collections.IDictionary] } | ForEach-Object { ([string]$_['name']).ToLowerInvariant() }) }
        $appRoles = @{}
        if ($graph.Data['graphApp']) { foreach ($r in @($graph.Data['graphApp']['appRoles'])) { if ($r) { $appRoles[([string]$r['id']).ToLowerInvariant()] = [string]$r['value'] } } }
        $activated = @($graph.Data['entraActive'] | Where-Object { $_ -and [string]$_['assignmentType'] -eq 'Activated' } | ForEach-Object { "$(([string]$_['principalId']).ToLowerInvariant())|$(([string]$_['roleDefinitionId']).ToLowerInvariant())" })
        $review = @{
            Assignment = $assignments; Definition = @($rbac.Rows['roleDefinitions'] | Where-Object { $_ }); Eligibility = $eligible.ToArray(); ScheduleInstance = $instances.ToArray(); ClassicAdmin = $classic.ToArray()
            SignIn = $signIns; ActivityDays = $request.ActivityDays; SubscriptionName = $scope.Names; SubscriptionChain = $chain; InScope = $scope.Ids
            EntraAssignment = @($graph.Data['entraAssignments'] | Where-Object { $_ }); EntraEligibility = @($graph.Data['entraEligibility'] | Where-Object { $_ }); EntraDefinition = @($graph.Data['entraDefinitions'] | Where-Object { $_ }); EntraActivated = $activated
            AppGrant = @($graph.Data['graphGrants'] | Where-Object { $_ }); GraphAppRole = $appRoles
        }
        if (-not $directory.Error) { $review.Directory = $directory.Objects }
        if ($null -ne $activity) { $review.Activity = $activity }
        $result = ConvertTo-AACAccessReview @review
        $notices = [System.Collections.Generic.List[string]]::new()
        foreach ($n in $result.Notices) { $notices.Add($n) }
        foreach ($n in $unreadArm) { $notices.Add("Couldn't read the $n") }
        if ($signInError) { $notices.Add("Privileged users' last sign-in couldn't be read (AuditLog.Read.All and Entra ID P1): $signInError") }
        $graphLabels = @{ entraAssignments = 'Entra ID role assignments'; entraEligibility = 'eligible Entra ID roles'; entraActive = 'Entra ID role activations'; entraDefinitions = 'Entra ID role names'; graphApp = 'Microsoft Graph app roles'; graphGrants = 'Microsoft Graph application permissions' }
        foreach ($key in @($graph.Errors.Keys | Sort-Object)) { if ($graph.Errors[$key]) { $notices.Add("Microsoft Graph refused the $($graphLabels[$key]): $($graph.Errors[$key])") } }
        Update-AACProgress -Id 'review' -Complete -Description ('{0:N0} assignment(s): {1:N0} critical, {2:N0} high - {3:N0} to remove, {4:N0} to make eligible' -f $result.Stats.Assignments, $result.Stats.Critical, $result.Stats.High, $result.Stats.ToRemove, $result.Stats.ToEligible)
        @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() }
    }

    $result = $state.Result
    $rows = @($result.Rows)
    if ($PrivilegedOnly) { $rows = @($rows | Where-Object Privileged -EQ 'Yes') }
    if ($Severity) { $rows = @($rows | Where-Object { $Severity -contains $_.Severity }) }
    $s = $result.Stats
    $rank = Get-AACSeverityRank
    $recommendTones = @{ Remove = 'bad'; 'Make eligible (PIM)' = 'warn'; 'Narrow the scope or role' = 'warn'; 'Review usage' = 'info'; 'Use a group' = 'info'; Keep = 'good' }
    $report = @{
        Subtitle = 'Access review: Azure RBAC, PIM, Entra ID roles and Microsoft Graph application permissions'
        Facts    = [ordered]@{ Scope = $state.Scope.Label; Activity = $(if ($ActivityDays) { "the last $ActivityDays day(s)" } else { 'not read' }); 'Entra ID' = $(if ($SkipEntra) { 'principals only (-SkipEntra)' } else { 'roles and Graph permissions' }) }
        Status   = $(if ($s.Critical -or $s.High) { 'Failed' } elseif ($s.ToRemove -or $s.ToEligible) { 'Warning' } else { 'Success' })
        Headline = "$($s.Assignments) assignment(s) for $($s.Principals) principal(s): $($s.Critical) critical, $($s.High) high - $($s.ToRemove) to remove, $($s.ToEligible) to make just-in-time"
        Tiles    = @(
            @{ Value = '{0:N0}' -f $s.Assignments; Label = 'assignments'; Tone = 'info'; Table = 'access' }
            @{ Value = '{0:N0}' -f $s.Privileged; Label = 'privileged'; Tone = 'violet'; Table = 'access'; Filters = @{ Privileged = 'Yes' } }
            @{ Value = '{0:N0}' -f $s.Standing; Label = 'standing privileged (users)'; Tone = $(if ($s.Standing) { 'bad' } else { 'good' }) }
            @{ Value = '{0:N0}' -f $s.Eligible; Label = 'eligible (PIM)'; Tone = 'good'; Table = 'access'; Filters = @{ Assignment = 'Eligible (PIM)' } }
            @{ Value = '{0:N0}' -f $s.Orphaned; Label = 'orphaned'; Tone = $(if ($s.Orphaned) { 'warn' } else { 'good' }) }
            @{ Value = $(if ($ActivityDays) { '{0:N0}' -f $s.Unused } else { '-' }); Label = 'unused write access'; Tone = $(if ($s.Unused) { 'warn' } else { 'good' }) }
            @{ Value = '{0:N0}' -f $s.Guests; Label = 'privileged guests'; Tone = $(if ($s.Guests) { 'bad' } else { 'good' }) }
            @{ Value = '{0:N0}' -f $s.RiskyApps; Label = 'apps with risky Graph access'; Tone = $(if ($s.RiskyApps) { 'bad' } else { 'good' }) }
        )
        Notices  = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'Recommendations'; Kind = 'donut'; CenterLabel = 'assignments'; Items = @($rows | Group-Object Recommendation | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $recommendTones[$_.Name]; Filter = $_.Name } }); Table = 'access'; Column = 'Recommendation'; Console = $true }
            @{ Title = 'Privileged access by principal type'; Items = @($rows | Where-Object Privileged -EQ 'Yes' | Group-Object PrincipalType | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'access'; Column = 'PrincipalType'; Tone = 'violet' }
            @{ Title = 'Assignments by scope'; Items = @($rows | Group-Object ScopeLevel | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'access'; Column = 'ScopeLevel'; Tone = 'info' }
        )
        Tables   = @(
            @{ Id = 'access'; Title = 'Access'; Section = 'Access'; Rows = $rows; Noun = 'assignments'; GroupBy = @('Severity', 'Recommendation', 'Principal', 'Role', 'Subscription', 'Source'); ConsoleLimit = 25
                Empty = 'No assignments match.'
                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'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Role'; Label = 'Role'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Scope'; Label = 'Scope'; Console = $true; Pdf = $true }
                    @{ Key = 'Assignment'; Label = 'Assignment'; Type = 'badge'; Tones = @{ Permanent = 'warn'; 'Activated (PIM)' = 'good'; 'Eligible (PIM)' = 'good' }; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Recommendation'; Label = 'Recommendation'; Type = 'badge'; Tones = $recommendTones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Findings'; Label = 'Findings'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Action'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'SignInName'; Label = 'Sign-in name'; Type = 'mono' }
                    @{ Key = 'Privileged'; Label = 'Privileged'; Type = 'badge'; Tones = @{ Yes = 'violet'; No = 'neutral' }; Facet = $true }
                    @{ Key = 'ScopeLevel'; Label = 'Scope level'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                    @{ Key = 'Source'; Label = 'Source'; Facet = $true }
                    @{ Key = 'Enabled'; Label = 'Enabled'; Type = 'badge'; Tones = @{ Yes = 'good'; No = 'bad' } }
                    @{ Key = 'LastSignIn'; Label = 'Last sign-in'; Type = 'datetime' }
                    @{ Key = 'LastActivity'; Label = 'Last change made'; Type = 'datetime' }
                    @{ Key = 'Created'; Label = 'Assigned'; Type = 'datetime' }
                    @{ Key = 'Decision'; Label = 'Decision' }
                    @{ Key = 'Reviewer'; Label = 'Reviewer' }
                ) }
        )
        Hint     = '-PrivilegedOnly or -Severity narrows the list; -CsvPath writes the attestation (Decision and Reviewer to fill in); -NoDisplay returns the rows.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun 'assignment' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $rows -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}