public/Get-MsecIntuneAuditEvent.ps1

function Get-MsecIntuneAuditEvent {
    <#
    .SYNOPSIS
        The Intune audit log - who changed which policy, when, and what the setting was
        before and after. Retains far longer than the Entra audit log, and is the only
        durable record of a configuration change that was later rolled back.

    .DESCRIPTION
        Reads /deviceManagement/auditEvents. One row per change, with the before and after
        values of each property that moved.

        THIS IS A DIFFERENT STORE FROM THE ENTRA AUDIT LOG, WITH A DIFFERENT RETENTION. The
        Entra directory audit log keeps 30 days on P1/P2 and 7 on the free tier, and
        Get-MsecEntraDisabledUser is built around that ceiling. Intune keeps its own audit
        separately and for much longer, so a policy change that is long gone from Entra is
        usually still here. Nothing in the endpoint path hints at this: both are "the audit
        log" in conversation, and reaching for the wrong one returns an empty result rather
        than an error.

        THE PERMISSION IS THE LEAST GUESSABLE IN THE MODULE. This endpoint is gated by
        DeviceManagementApps.Read.All - the Intune APPS scope. It is NOT covered by
        DeviceManagementConfiguration.Read.All, which reads the very policies whose changes
        are logged here, nor by DeviceManagementManagedDevices.Read.All, nor by
        AuditLog.Read.All, which is Entra's. Without it the endpoint returns a bare 403 that
        names nothing, so the 403 is caught and rewritten.

        THE WINDOW ACTUALLY RETURNED IS REPORTED, NOT ASSUMED. Microsoft does not state the
        retention on the API reference, and it is not worth guessing in a module anyone can
        run against any tenant. So -Days sets what is ASKED FOR, and the verbose stream
        reports the oldest event that actually came back. If you ask for 365 days and the
        oldest row is 200 days old, that is the real floor of what this tenant can tell you,
        and it is a different answer from "nothing happened before then".

        AN EMPTY RESULT IS WARNED ABOUT, NEVER RETURNED SILENTLY. "No changes in the window"
        and "the window does not reach back far enough" look identical in an empty array, and
        the second one is the answer that matters when you are trying to date a change that
        somebody rolled back.

        A FILTER THAT MATCHES NOTHING SAYS WHAT WAS THERE INSTEAD. Resource names are whatever
        somebody typed into Intune, so a reasonable-looking -Resource finds nothing and reads
        as "that policy was never touched". Measured: searching a real tenant for 'Attack
        Surface' returned zero against 2,798 events, because the policy is called 'Block use of
        copied or impersonated system tools'. When a filter eliminates every row, the values
        actually present are named on the warning stream rather than left to be guessed.

        ACTIVITY FALLS BACK TO DISPLAYNAME. Graph returns the `activity` property EMPTY on every
        row of some tenants - measured 2,798 of 2,798 - while `displayName` carries the verb
        ('Create device configuration 2.0 (beta)'). Activity is therefore the first of the two
        that is non-empty, so the column is never blank when the information exists.

        CHANGEDPROPERTIES IS THE POINT. An audit event names the policy that was touched;
        ChangedProperties names the settings inside it and carries old -> new for each. That
        is what tells an ASR rule moving from Audit to Block apart from someone renaming the
        policy. It is a string[] so it can be searched with -match without parsing a blob,
        and the structured originals stay in Raw.

    .PARAMETER Days
        How far back to ask for, counted from now. Default 30. This sets the REQUEST, not
        the retention - see the description. Use -Verbose to see what actually came back.

    .PARAMETER Category
        Only events in this category, for example 'DeviceConfiguration', 'Enrollment',
        'Compliance', 'Application'. Completed from the categories this tenant has actually
        produced rather than a fixed list, because the set is not documented and differs
        between tenants.

    .PARAMETER Activity
        Substring match on the activity and its display name, case-insensitive. 'Patch' finds
        every update; 'Delete' every removal.

    .PARAMETER Resource
        Substring match on the name of the thing that was changed - the policy, profile or
        app. This is usually how you find a specific policy's history.

    .PARAMETER ResourceType
        Only events against this kind of thing - 'DeviceManagementConfigurationPolicy' for
        Settings Catalog and endpoint security policies, 'ManagedDevice', 'MobileApp' and so on.
        Completed from the types Intune actually emits. Unlike -Resource, these are Microsoft's
        own type names rather than whatever a policy was called, so they are stable to filter on.

    .PARAMETER Actor
        Substring match on who did it: user principal name, application display name, or
        service principal name. Covers changes made by an app as well as by a person.

    .PARAMETER FailedOnly
        Only events whose activityResult is not a success. A failed change attempt is its own
        signal - somebody tried and lacked the rights.

    .EXAMPLE
        Get-MsecIntuneAuditEvent -Days 365 -Resource 'Attack Surface' -Verbose

        Every change to an ASR policy in the last year, with the window actually available
        reported on the verbose stream. This is the query that dates a rule that was enabled
        and later rolled back.

    .EXAMPLE
        Get-MsecIntuneAuditEvent -Days 365 |
            Where-Object { $_.ChangedProperties -match 'Block|Audit|Warn' } |
            Select-Object ActivityDateTime, Actor, Resource, ChangedProperties

        Changes that moved an enforcement mode, whichever policy they were in.

    .EXAMPLE
        Get-MsecIntuneAuditEvent -Days 90 | Group-Object Actor | Sort-Object Count -Descending

        Who is changing Intune. A service principal near the top is worth knowing about.

    .EXAMPLE
        Get-MsecIntuneAuditEvent -Days 365 -ResourceType DeviceManagementConfigurationPolicy |
            Group-Object { $_.Resource } | Sort-Object Count -Descending

        Every policy touched in the year, busiest first. Run this BEFORE guessing at -Resource:
        the names are whatever somebody typed, and a policy enforcing an ASR rule is as likely
        to be called 'Block use of copied or impersonated system tools' as anything with 'ASR'
        in it.

    .EXAMPLE
        Get-MsecIntuneAuditEvent -Days 90 -FailedOnly

        Attempted changes that did not succeed.

    .OUTPUTS
        PSCustomObject per audit event, PSTypeName 'MsecIntuneAuditEvent'.

    .NOTES
        Needs the 'DeviceManagementApps.Read.All' application permission, which New-MsecApp
        grants. An app created before that was added must re-run New-MsecApp.

        Also needs an active Intune licence on the tenant - the Graph API for Intune returns
        an error rather than an empty list without one.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [ValidateRange(1, 3650)]
        [int] $Days = 30,

        # Deliberately an ArgumentCompleter and not a ValidateSet. The category list is not
        # documented, tenants produce different sets, and a ValidateSet would reject a real
        # value this tenant returns - the same mistake that would have made 'AlternateMitigation'
        # unreachable in Get-MsecSecureScoreRecommendation.
        [ArgumentCompleter({
            param($c, $p, $wordToComplete)
            @('Application', 'Compliance', 'Device', 'DeviceConfiguration', 'DeviceIntent',
              'Enrollment', 'Other', 'Role', 'RoleBasedAccessControl') |
                Where-Object { $_ -like "$wordToComplete*" } |
                ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
        })]
        [string] $Category,

        [string] $Activity,

        [string] $Resource,

        # Microsoft's own type names, so unlike -Resource these are stable across tenants.
        # An ArgumentCompleter rather than a ValidateSet for the same reason as -Category:
        # Intune emits types that are not in any published list, and rejecting a real one
        # would be worse than completing an incomplete set.
        [ArgumentCompleter({
            param($c, $p, $wordToComplete)
            @('DeviceManagementConfigurationPolicy', 'DeviceManagementConfigurationPolicyAssignment',
              'DeviceConfiguration', 'DeviceConfigurationAssignment', 'DeviceManagementIntent',
              'ManagedDevice', 'MobileApp', 'MobileAppAssignment', 'Win32LobApp',
              'Windows10CompliancePolicy', 'MacOSCompliancePolicy',
              'WindowsUpdateForBusinessConfiguration', 'DeviceHealthScript',
              'DeviceManagementScript', 'DepOnboardingSetting', 'RoleDefinition') |
                Where-Object { $_ -like "$wordToComplete*" } |
                ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
        })]
        [string] $ResourceType,

        [string] $Actor,

        [switch] $FailedOnly
    )

    Assert-MsecSession

    $since = (Get-Date).ToUniversalTime().AddDays(-$Days)
    # Graph wants an unquoted ISO 8601 literal here. 'o' round-trip format is what it accepts;
    # a locale-formatted string 400s with a filter-parse error that does not name the date.
    $filter = "activityDateTime ge $($since.ToString('yyyy-MM-ddTHH:mm:ssZ'))"
    $path = "/v1.0/deviceManagement/auditEvents?`$filter=$([uri]::EscapeDataString($filter))&`$top=1000"

    try {
        $events = @(Invoke-MsecGraphRequest -Path $path -All)
    }
    catch {
        $message = "$($_.Exception.Message)"
        if ($message -match '403|Forbidden') {
            throw "Forbidden when calling /deviceManagement/auditEvents. This endpoint is gated by the 'DeviceManagementApps.Read.All' application permission - the Intune APPS scope, which is NOT implied by DeviceManagementConfiguration.Read.All, DeviceManagementManagedDevices.Read.All or AuditLog.Read.All. Re-run New-MsecApp to add and consent it, then Disconnect-Msec / Connect-Msec. Original error: $message"
        }
        if ($message -match 'Intune|license|licence') {
            throw "The Intune Graph API rejected the call, which usually means the tenant has no active Intune licence rather than that there are no audit events. Original error: $message"
        }
        throw
    }

    if (-not $events.Count) {
        Write-Warning "No Intune audit events returned for the last $Days day(s). This is NOT the same as 'nothing was changed' - it is also what an out-of-retention window looks like. Try a larger -Days to find where this tenant's audit history actually starts."
        return
    }

    # Report the window that actually came back. Asking for 365 days and receiving 200 is the
    # real floor of what this tenant can answer, and it is not visible in the rows themselves.
    $dates = @($events | ForEach-Object { if ($_.activityDateTime) { [datetime]$_.activityDateTime } }) | Sort-Object
    if ($dates.Count) {
        $oldestDays = [math]::Round(((Get-Date).ToUniversalTime() - $dates[0].ToUniversalTime()).TotalDays, 1)
        Write-Verbose "Asked for $Days day(s); $($events.Count) event(s) returned, oldest $($dates[0].ToString('u')) ($oldestDays days ago), newest $($dates[-1].ToString('u'))."
        if ($oldestDays -lt ($Days * 0.9)) {
            Write-Verbose "The oldest event is well inside the requested window, so either nothing older was changed or this tenant's audit history does not reach back $Days days. The two are indistinguishable from here."
        }
    }

    $emitted = 0
    $rows = foreach ($e in $events) {
        if ($Category -and "$($e.category)" -ne $Category) { continue }
        if ($Activity -and "$($e.activity) $($e.displayName)" -notmatch [regex]::Escape($Activity)) { continue }

        # An actor is a person OR an application OR a service principal, and which fields are
        # populated depends on how the change was made. A change made by a script through an
        # app registration has no userPrincipalName at all, so taking only that field would
        # report the most interesting changes as having no author.
        $actorName = @(
            $e.actor.userPrincipalName
            $e.actor.servicePrincipalName
            $e.actor.applicationDisplayName
        ) | Where-Object { $_ } | Select-Object -First 1
        if (-not $actorName) { $actorName = $null }

        if ($Actor -and ("$actorName" -notmatch [regex]::Escape($Actor))) { continue }

        $resources = @($e.resources | ForEach-Object { $_.displayName } | Where-Object { $_ })
        $resourceTypes = @($e.resources | ForEach-Object { $_.auditResourceType } | Where-Object { $_ })
        if ($Resource) {
            $resourceText = ($resources -join ' ') + ' ' + (@($e.resources | ForEach-Object { $_.type }) -join ' ')
            if ($resourceText -notmatch [regex]::Escape($Resource)) { continue }
        }
        if ($ResourceType -and ($resourceTypes -notcontains $ResourceType)) { continue }

        # activityResult is free text, not an enum - 'Success', 'Succeeded' and failure strings
        # all appear. Tested for success rather than for failure so an unfamiliar value counts
        # as a failure and gets looked at, instead of being quietly filtered out.
        $succeeded = "$($e.activityResult)" -match '^succe'
        if ($FailedOnly -and $succeeded) { continue }

        $changed = @(
            foreach ($r in $e.resources) {
                foreach ($mp in $r.modifiedProperties) {
                    $old = if ($null -ne $mp.oldValue -and "$($mp.oldValue)" -ne '') { "$($mp.oldValue)" } else { '(none)' }
                    $new = if ($null -ne $mp.newValue -and "$($mp.newValue)" -ne '') { "$($mp.newValue)" } else { '(none)' }
                    "$($mp.displayName): $old -> $new"
                }
            }
        )

        [PSCustomObject]@{
            PSTypeName       = 'MsecIntuneAuditEvent'
            Id               = [string] $e.id
            ActivityDateTime = if ($e.activityDateTime) { [datetime]$e.activityDateTime } else { $null }
            # Graph returns `activity` EMPTY on every row of some tenants while `displayName`
            # carries the verb, so this is the first of the two that has anything in it. A
            # blank column here would read as an event with no action.
            Activity         = $(if ("$($e.activity)") { [string] $e.activity } else { [string] $e.displayName })
            DisplayName      = [string] $e.displayName
            Category         = [string] $e.category
            ComponentName    = [string] $e.componentName
            OperationType    = [string] $e.activityOperationType
            ActivityType     = [string] $e.activityType
            Result           = [string] $e.activityResult
            Succeeded        = $succeeded
            Actor            = $actorName
            ActorType        = [string] $e.actor.auditActorType
            ActorIpAddress   = [string] $e.actor.ipAddress
            ActorAppId       = [string] $e.actor.applicationId
            # The things that were changed. Usually one; a bulk edit can name several.
            Resource         = $resources
            ResourceType     = $resourceTypes
            ResourceId       = @($e.resources | ForEach-Object { $_.resourceId } | Where-Object { $_ })
            # 'Setting: old -> new' per property that moved. The reason this command exists.
            ChangedProperties = $changed
            ChangedCount     = $changed.Count
            CorrelationId    = [string] $e.correlationId
            Raw              = $e
        }
        $emitted++
    }

    $rows

    # A FILTER THAT ELIMINATES EVERYTHING IS NOT THE SAME ANSWER AS AN EMPTY AUDIT LOG, and the
    # two are the same empty array to the caller. Resource names in particular are whatever
    # somebody typed into Intune, so a sensible-looking guess finds nothing and reads as "that
    # was never changed". Naming what is actually present turns a dead end into the next query.
    if ($emitted -eq 0) {
        $present = @(
            if ($Resource -or $ResourceType) {
                # Ranked by how often each was touched, NOT alphabetically. Sorted by name, a
                # tenant's list opens with auto-generated GUID-and-timestamp resource names and
                # the policy the reader is looking for is 300 entries down - which is a longer
                # way of saying nothing. Busiest first puts the real policies at the top.
                $flat = @($events | ForEach-Object { $_.resources })
                if ($ResourceType) { $flat = @($flat | Where-Object { $_.auditResourceType -eq $ResourceType }) }
                $types = @($events | ForEach-Object { $_.resources } | Where-Object { $_.auditResourceType } |
                           Group-Object { $_.auditResourceType } | Sort-Object Count -Descending)
                $names = @($flat | Where-Object { $_.displayName } | Group-Object { $_.displayName } |
                           Sort-Object Count -Descending)
                if (-not $ResourceType -and $types) {
                    "ResourceType values present (busiest first): $((($types | Select-Object -First 10).Name) -join ', ')$(if ($types.Count -gt 10) { " (+$($types.Count - 10) more)" })"
                }
                if ($names) {
                    $scope = if ($ResourceType) { "Resource names of type '$ResourceType'" } else { 'Resource names' }
                    "$scope (busiest first): $((($names | Select-Object -First 10).Name) -join '; ')$(if ($names.Count -gt 10) { " (+$($names.Count - 10) more)" })"
                }
                elseif ($ResourceType) { "No resource of type '$ResourceType' appears in the window at all - check the spelling against the ResourceType list." }
            }
            if ($Category) {
                $cats = @($events | ForEach-Object { $_.category } | Where-Object { $_ } | Sort-Object -Unique)
                if ($cats) { "Category values present: $($cats -join ', ')" }
            }
        )
        Write-Warning ("$($events.Count) audit event(s) were returned for the last $Days day(s), but no row matched the filters, so this is a FILTER miss and NOT an empty audit log. " + ($present -join ' | '))
    }
}