Public/Get-AACGovernanceChange.ps1

function Get-AACGovernanceChange {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Shows who changed policy and access in the last days - policy
        definitions, initiatives and assignments, custom roles and role
        assignments created or updated - with the author resolved to a name
        and the notable changes flagged.
    .DESCRIPTION
        Reads, read-only and tenant-wide in Azure Resource Graph, the
        created and updated times and authors Azure keeps on each policy
        definition, initiative and assignment (their metadata), custom role
        and role assignment, and looks the authors up in Entra ID.
 
        A row per change (AAC.GovernanceChange), newest first: when, created
        or updated, what, its name and scope, who, and Detail - the role and
        principal of a role assignment, the enforcement of a policy
        assignment. Medium: a privileged role assigned (Owner, Contributor,
        User Access Administrator, RBAC Administrator), a custom role
        changed, a policy assignment that does not enforce.
 
        Get-AACChangeHistory covers resources (Change Analysis); this covers
        the governance around them. Azure keeps only the latest update of
        each item: for every update, export the activity log. Reader is
        enough; Directory.Read.All for the authors' names.
    .PARAMETER SubscriptionId
        Only changes in these subscriptions (management group and root ones
        above them always count).
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER Days
        How far back. Default 30.
    .PARAMETER Kind
        Only these: PolicyDefinition, Initiative, PolicyAssignment,
        CustomRole, RoleAssignment.
    .PARAMETER By
        Only changes by these people or applications (wildcards work).
    .PARAMETER SkipEntra
        Don't look up the authors: they are shown by ID.
    .PARAMETER CsvPath
        Write the changes to this CSV file.
    .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 changes.
    .PARAMETER NoDisplay
        Return the changes without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACGovernanceChange
        The last 30 days of policy and access changes, newest first.
    .EXAMPLE
        Get-AACGovernanceChange -Days 7 -Kind RoleAssignment -NoDisplay | Where-Object Severity -EQ 'Medium'
        Privileged access granted this week.
    .EXAMPLE
        Get-AACGovernanceChange -Days 90 -By '*@contoso.com' -HtmlPath .\Changes.html
        A quarter's changes by people, as a report.
    .OUTPUTS
        AAC.GovernanceChange
    #>

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

        [string[]] $ManagementGroupId,

        [ValidateRange(1, 365)]
        [int] $Days = 30,

        [ValidateSet('PolicyDefinition', 'Initiative', 'PolicyAssignment', 'CustomRole', 'RoleAssignment')]
        [string[]] $Kind,

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

        [switch] $SkipEntra,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Governance changes',

        [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 { $_ }); Days = $Days; Entra = -not $SkipEntra }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Governance changes' -Color 'gold1' }
    $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)"
        $since = [datetime]::UtcNow.AddDays(-$request.Days)
        $window = "where todatetime(createdOn) > ago($($request.Days)d) or todatetime(updatedOn) > ago($($request.Days)d)"
        $policy = "createdOn = tostring(properties.metadata.createdOn), createdBy = tostring(properties.metadata.createdBy), updatedOn = tostring(properties.metadata.updatedOn), updatedBy = tostring(properties.metadata.updatedBy)"
        $atScope = "scope = tolower(substring(id, 0, indexof(tolower(id), '/providers/microsoft.authorization/')))"
        $query = [ordered]@{
            policyDefinitions = @{ Tenant = $true; Query = "policyresources | where type =~ 'microsoft.authorization/policydefinitions' and tostring(properties.policyType) =~ 'Custom' | project id, name = tostring(properties.displayName), $atScope, $policy | $window" }
            policySets        = @{ Tenant = $true; Query = "policyresources | where type =~ 'microsoft.authorization/policysetdefinitions' and tostring(properties.policyType) =~ 'Custom' | project id, name = tostring(properties.displayName), $atScope, $policy | $window" }
            policyAssignments = @{ Tenant = $true; Query = "policyresources | where type =~ 'microsoft.authorization/policyassignments' | project id, name = tostring(properties.displayName), scope = tolower(tostring(properties.scope)), enforcement = tostring(properties.enforcementMode), $policy | $window" }
            roleDefinitions   = @{ Tenant = $true; Query = "authorizationresources | where type =~ 'microsoft.authorization/roledefinitions' and tostring(properties.type) =~ 'CustomRole' | project id, name = tostring(properties.roleName), scope = tolower(tostring(properties.assignableScopes[0])), createdOn = tostring(properties.createdOn), createdBy = tostring(properties.createdBy), updatedOn = tostring(properties.updatedOn), updatedBy = tostring(properties.updatedBy) | $window" }
            roleAssignments   = @{ Tenant = $true; Query = "authorizationresources | where type =~ 'microsoft.authorization/roleassignments' | project id, name, scope = tolower(tostring(properties.scope)), roleId = tolower(tostring(properties.roleDefinitionId)), principalId = tostring(properties.principalId), principalType = tostring(properties.principalType), createdOn = tostring(properties.createdOn), createdBy = tostring(properties.createdBy), updatedOn = tostring(properties.updatedOn), updatedBy = tostring(properties.updatedBy) | $window" }
            roleNames         = @{ Tenant = $true; Query = "authorizationresources | where type =~ 'microsoft.authorization/roledefinitions' | project id = tolower(id), roleName = tostring(properties.roleName)" }
        }
        Update-AACProgress -Id 'read' -Total $query.Count -Description "Reading $($request.Days) days of policy and access changes"
        $read = Invoke-AACGraphBatch -Query $query -AllowFailure @($query.Keys) -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 the changes'

        $directory = $null
        if ($request.Entra) {
            $ids = @(foreach ($k in 'policyDefinitions', 'policySets', 'policyAssignments', 'roleDefinitions', 'roleAssignments') { foreach ($row in @($read.Rows[$k] | Where-Object { $_ })) { [string]$row['createdBy']; [string]$row['updatedBy']; [string]$row['principalId'] } })
            $ids = @($ids | Where-Object { $_ -match '^[0-9a-fA-F-]{36}$' } | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique)
            Update-AACProgress -Id 'authors' -Indeterminate -Description "Looking up $($ids.Count) author(s) and principal(s)"
            $directory = Get-AACDirectoryObject -Id $ids
            Update-AACProgress -Id 'authors' -Complete -Description ('Looked up {0:N0} author(s) and principal(s)' -f $directory.Objects.Count)
        }
        # In scope: the subscriptions in scope and anything above them (management groups, root).
        $subs = @{}; foreach ($id in $scope.Ids) { $subs[$id.ToLowerInvariant()] = $true }
        $groups = @{}; foreach ($s in $scope.Subscriptions) { foreach ($m in @($s['chain'])) { if ($m -is [System.Collections.IDictionary]) { $groups[([string]$m['name']).ToLowerInvariant()] = $true } } }
        $unscoped = -not @($scope.GraphScope).Count
        $inScope = {
            param([string] $Scope)
            if ($unscoped -or -not $Scope -or $Scope -eq '/') { return $true }
            if ($Scope -match '^/subscriptions/([^/]+)') { return $subs.Contains($Matches[1].ToLowerInvariant()) }
            if ($Scope -match '/managementgroups/([^/]+)') { return $groups.Contains($Matches[1].ToLowerInvariant()) }
            $true
        }.GetNewClosure()
        @{ Result = (ConvertTo-AACGovernanceChange -Read $read -Directory $directory -Since $since -InScope $inScope); Scope = $scope }
    }

    $changes = @($outcome.Result.Changes)
    if ($Kind) { $kinds = @{ PolicyDefinition = 'Policy definition'; Initiative = 'Initiative'; PolicyAssignment = 'Policy assignment'; CustomRole = 'Custom role'; RoleAssignment = 'Role assignment' }; $wanted = @($Kind | ForEach-Object { $kinds[$_] }); $changes = @($changes | Where-Object { $wanted -contains $_.Kind }) }
    if ($By) { $changes = @($changes | Where-Object { $author = $_.By; @($By | Where-Object { $author -like $_ }).Count }) }
    $notable = @($changes | Where-Object Severity -EQ 'Medium').Count
    $rank = Get-AACSeverityRank
    $report = @{
        Subtitle = 'Governance changes: policy and access created or updated, and who did it'
        Facts    = [ordered]@{ Scope = $outcome.Scope.Label; Window = "last $Days days"; Authors = $(if ($SkipEntra) { 'by ID (-SkipEntra)' } else { 'resolved in Entra ID' }) }
        Status   = $(if ($notable) { 'Warning' } else { 'Success' })
        Headline = $(if ($changes.Count) { "$($changes.Count) change(s) in $Days days by $(@($changes | ForEach-Object { $_.By } | Where-Object { $_ } | Select-Object -Unique).Count) author(s); $notable notable" } else { "No policy or access changes in the last $Days days." })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $changes.Count; Label = 'changes'; Tone = 'info' }
            @{ Value = '{0:N0}' -f $notable; Label = 'notable'; Tone = $(if ($notable) { 'warn' } else { 'good' }); Table = 'changes'; Filters = @{ Severity = 'Medium' } }
            @{ Value = '{0:N0}' -f @($changes | Where-Object Kind -EQ 'Role assignment').Count; Label = 'role assignments'; Tone = 'violet'; Table = 'changes'; Filters = @{ Kind = 'Role assignment' } }
            @{ Value = '{0:N0}' -f @($changes | Where-Object Kind -Like 'Polic*').Count; Label = 'policy changes'; Tone = 'info' }
        )
        Notices  = @($outcome.Result.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'By day'; Items = @($changes | Group-Object { $_.When.ToString('yyyy-MM-dd') } | Sort-Object Name | ForEach-Object { @{ Label = $_.Name; Value = $_.Count } }); Tone = 'info' }
            @{ Title = 'By kind'; Items = @($changes | Group-Object Kind | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'changes'; Column = 'Kind'; Tone = 'violet'; Console = $true }
            @{ Title = 'Most changes by'; Items = @($changes | Where-Object By | Group-Object By | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'changes'; Column = 'By'; Tone = 'warn' }
        )
        Tables   = @(
            @{ Id = 'changes'; Title = 'Changes'; Section = 'Changes'; Rows = $changes; Noun = 'changes'; GroupBy = @('Kind', 'By'); ConsoleLimit = 30
                Empty = "No policy or access changes in the last $Days days."; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'When'; Label = 'When'; Type = 'datetime'; Console = $true; Pdf = $true }
                    @{ Key = 'Change'; Label = 'Change'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Kind'; Label = 'What'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Name'; Label = 'Name'; Console = $true; Pdf = $true }
                    @{ Key = 'Scope'; Label = 'Scope'; Console = $true }
                    @{ Key = 'By'; Label = 'By'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Note'; Label = 'Why it matters'; Type = 'wide' }
                    @{ Key = 'Id'; Label = 'ID'; Type = 'mono' }
                ) }
        )
        Hint     = '-Days sets the window; -Kind and -By narrow it; -NoDisplay returns the changes; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $changes -Noun 'change' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $changes -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}