Private/Get-IntuneAccessChangeTimeline.ps1

function Compare-IntuneAccessOutcomeTransition {
    <#
    Compares reported outcomes between two collections by workload and device.
    Returns NewFailure, Recovered, NewResult and ResultRemoved transitions only.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyCollection()] [object[]] $Before = @(),
        [AllowEmptyCollection()] [object[]] $After = @()
    )

    function Get-OutcomeKey([object] $Outcome) {
        $device = [string] (Get-IntuneAccessProperty $Outcome 'DeviceId' '')
        if ([string]::IsNullOrWhiteSpace($device)) { $device = 'name:' + [string] (Get-IntuneAccessProperty $Outcome 'DeviceName' '') }
        '{0}|{1}' -f [string] (Get-IntuneAccessProperty $Outcome 'WorkloadId' ''), $device
    }

    $beforeMap = @{}
    foreach ($item in @($Before)) { if ($null -ne $item) { $beforeMap[(Get-OutcomeKey $item)] = $item } }
    $afterMap = @{}
    foreach ($item in @($After)) { if ($null -ne $item) { $afterMap[(Get-OutcomeKey $item)] = $item } }

    $transitions = [System.Collections.Generic.List[object]]::new()
    foreach ($key in @($beforeMap.Keys + $afterMap.Keys | Sort-Object -Unique)) {
        $old = if ($beforeMap.ContainsKey($key)) { $beforeMap[$key] } else { $null }
        $new = if ($afterMap.ContainsKey($key)) { $afterMap[$key] } else { $null }
        $oldCategory = [string] (Get-IntuneAccessProperty $old 'Category' '')
        $newCategory = [string] (Get-IntuneAccessProperty $new 'Category' '')
        $transition = if ($null -eq $old -and $newCategory -eq 'Error') { 'NewFailure' }
            elseif ($null -eq $old) { 'NewResult' }
            elseif ($null -eq $new) { 'ResultRemoved' }
            elseif ($oldCategory -ne 'Error' -and $newCategory -eq 'Error') { 'NewFailure' }
            elseif ($oldCategory -eq 'Error' -and $newCategory -eq 'Success') { 'Recovered' }
            else { '' }
        if (-not $transition) { continue }
        $record = if ($null -ne $new) { $new } else { $old }
        $transitions.Add([PSCustomObject] @{
            PSTypeName      = 'IntuneAccess.OutcomeTransition'
            Transition      = $transition
            WorkloadId      = [string] (Get-IntuneAccessProperty $record 'WorkloadId' '')
            WorkloadName    = [string] (Get-IntuneAccessProperty $record 'WorkloadName' '')
            DeviceId        = [string] (Get-IntuneAccessProperty $record 'DeviceId' '')
            DeviceName      = [string] (Get-IntuneAccessProperty $record 'DeviceName' '')
            BeforeState     = [string] (Get-IntuneAccessProperty $old 'State' '')
            AfterState      = [string] (Get-IntuneAccessProperty $new 'State' '')
            AfterErrorCode  = Get-IntuneAccessProperty $new 'ErrorCode' $null
            ReportedAt      = [string] (Get-IntuneAccessProperty $record 'LastReportedDateTime' '')
        })
    }
    $transitions.ToArray()
}

function Get-IntuneAccessChangeTimeline {
    <#
    Builds one "what changed?" timeline from Intune audit events, snapshot configuration
    changes and reported outcome changes. Configuration changes are separated from
    check-in and inventory refreshes. A failure reported after a change on the same
    workload is shown as correlation in time, never as proof of cause.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyCollection()] [object[]] $AuditEvent = @(),
        [AllowNull()] [object] $SnapshotComparison,
        [AllowEmptyCollection()] [object[]] $DeploymentOutcome = @(),
        [AllowEmptyCollection()] [object[]] $Workload = @(),
        [DateTimeOffset] $AsOf = [DateTimeOffset]::UtcNow,
        [ValidateRange(1, 720)] [int] $CorrelationHours = 72
    )

    function ConvertTo-TimelineTime([object] $Value) {
        $parsed = [DateTimeOffset]::MinValue
        if ($null -ne $Value -and [DateTimeOffset]::TryParse([string] $Value, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal, [ref] $parsed)) { return $parsed }
        $null
    }

    $workloadNames = @{}
    foreach ($item in @($Workload)) { $workloadNames[[string] (Get-IntuneAccessProperty $item 'Id' '')] = [string] (Get-IntuneAccessProperty $item 'Name' '') }

    $configurationTypes = @('RoleAssignment', 'RoleDefinition', 'ScopeTag', 'Workload', 'WorkloadAssignment', 'AssignmentFilter', 'PolicySetting', 'AdminGroup', 'ScopeGroup', 'WorkloadGroup', 'Membership', 'Administrator', 'Permission')
    $entries = [System.Collections.Generic.List[object]]::new()

    foreach ($auditRecord in @($AuditEvent)) {
        $resourceIds = @(Get-IntuneAccessProperty $auditRecord 'ResourceIds' @())
        $resources = @(Get-IntuneAccessProperty $auditRecord 'Resources' @())
        $changed = @($resources | ForEach-Object { foreach ($property in @(Get-IntuneAccessProperty $_ 'ModifiedProperties' @())) { '{0}: {1} > {2}' -f $property.DisplayName, $(if ($property.OldValue) { $property.OldValue } else { '(empty)' }), $property.NewValue } })
        $entries.Add([PSCustomObject] @{
            PSTypeName   = 'IntuneAccess.TimelineEntry'
            Time         = ConvertTo-TimelineTime (Get-IntuneAccessProperty $auditRecord 'ActivityDateTime')
            TimeIsRange  = $false
            Kind         = 'Configuration'
            Source       = 'Intune audit log'
            Title        = [string] (Get-IntuneAccessProperty $auditRecord 'Activity' (Get-IntuneAccessProperty $auditRecord 'DisplayName' 'Audit event'))
            Target       = (($resources | ForEach-Object { $_.DisplayName } | Where-Object { $_ }) -join ', ')
            Actor        = [string] (Get-IntuneAccessProperty $auditRecord 'ActorUserPrincipalName' '')
            Details      = $changed
            WorkloadIds  = @($resourceIds | Where-Object { $workloadNames.ContainsKey([string] $_) })
            DeviceNames  = @()
            Correlated   = @()
        })
    }

    $hiddenRefreshes = 0
    if ($null -ne $SnapshotComparison) {
        $referenceAt = ConvertTo-TimelineTime (Get-IntuneAccessProperty $SnapshotComparison 'ReferenceAt')
        $differenceAt = ConvertTo-TimelineTime (Get-IntuneAccessProperty $SnapshotComparison 'DifferenceAt')
        foreach ($change in @(Get-IntuneAccessProperty $SnapshotComparison 'Changes' @())) {
            if ([string] $change.EntityType -notin $configurationTypes) { $hiddenRefreshes++; continue }
            if (@(Get-IntuneAccessProperty $change 'AuditEvents' @()).Count -gt 0) { continue }
            $record = if ($null -ne $change.After) { $change.After } else { $change.Before }
            $workloadId = [string] (Get-IntuneAccessProperty $record 'WorkloadId' '')
            if (-not $workloadId -and $change.EntityType -eq 'Workload') { $workloadId = [string] (Get-IntuneAccessProperty $record 'Id' '') }
            $entries.Add([PSCustomObject] @{
                PSTypeName   = 'IntuneAccess.TimelineEntry'
                Time         = $differenceAt
                TimeIsRange  = $true
                Kind         = 'Configuration'
                Source       = 'Snapshot comparison'
                Title        = ('{0} {1}' -f $change.EntityType, $change.ChangeType.ToLowerInvariant())
                Target       = [string] $change.Name
                Actor        = ''
                Details      = @(if ($change.ChangeType -eq 'Modified') { 'Changed: ' + (@($change.ChangedProperties) -join ', ') } else { '' }) + @("Detected between $referenceAt and $differenceAt; no matching audit event was returned.")
                WorkloadIds  = @($workloadId | Where-Object { $_ })
                DeviceNames  = @()
                Correlated   = @()
            })
        }

        foreach ($transition in @(Get-IntuneAccessProperty $SnapshotComparison 'OutcomeTransitions' @())) {
            $label = switch ($transition.Transition) { 'NewFailure' { 'New failure reported' } 'Recovered' { 'Recovered' } 'NewResult' { 'New result reported' } default { 'Result no longer reported' } }
            $entries.Add([PSCustomObject] @{
                PSTypeName   = 'IntuneAccess.TimelineEntry'
                Time         = $(if ($transition.ReportedAt) { ConvertTo-TimelineTime $transition.ReportedAt } else { $differenceAt })
                TimeIsRange  = -not [bool] $transition.ReportedAt
                Kind         = 'Outcome'
                Source       = 'Reported outcome'
                Title        = $label
                Target       = ('{0} on {1}' -f $transition.WorkloadName, $transition.DeviceName)
                Actor        = ''
                Details      = @("State: $(if ($transition.BeforeState) { $transition.BeforeState } else { '(none)' }) > $(if ($transition.AfterState) { $transition.AfterState } else { '(none)' })")
                WorkloadIds  = @($transition.WorkloadId)
                DeviceNames  = @($transition.DeviceName)
                Correlated   = @()
                Transition   = $transition.Transition
            })
        }
    }
    else {
        # Without a baseline, current failures are still placed on the timeline at their report time.
        foreach ($outcome in @($DeploymentOutcome | Where-Object { [string] (Get-IntuneAccessProperty $_ 'Category' '') -eq 'Error' })) {
            $entries.Add([PSCustomObject] @{
                PSTypeName   = 'IntuneAccess.TimelineEntry'
                Time         = ConvertTo-TimelineTime (Get-IntuneAccessProperty $outcome 'LastReportedDateTime')
                TimeIsRange  = $false
                Kind         = 'Outcome'
                Source       = 'Reported outcome'
                Title        = 'Failure reported'
                Target       = ('{0} on {1}' -f (Get-IntuneAccessProperty $outcome 'WorkloadName' ''), (Get-IntuneAccessProperty $outcome 'DeviceName' ''))
                Actor        = ''
                Details      = @("State: $(Get-IntuneAccessProperty $outcome 'State' '')")
                WorkloadIds  = @([string] (Get-IntuneAccessProperty $outcome 'WorkloadId' ''))
                DeviceNames  = @([string] (Get-IntuneAccessProperty $outcome 'DeviceName' ''))
                Correlated   = @()
                Transition   = 'CurrentFailure'
            })
        }
    }

    # Correlate configuration changes with failures reported afterwards on the same workload.
    $failures = @($entries | Where-Object { $_.Kind -eq 'Outcome' -and $null -ne $_.Time -and $_.PSObject.Properties['Transition'] -and $_.Transition -in @('NewFailure', 'CurrentFailure') })
    foreach ($entry in @($entries | Where-Object { $_.Kind -eq 'Configuration' -and $null -ne $_.Time -and -not $_.TimeIsRange -and @($_.WorkloadIds).Count -gt 0 })) {
        $windowEnd = $entry.Time.AddHours($CorrelationHours)
        $after = @($failures | Where-Object { $_.Time -ge $entry.Time -and $_.Time -le $windowEnd -and @($_.WorkloadIds | Where-Object { $_ -in $entry.WorkloadIds }).Count -gt 0 })
        if ($after.Count -gt 0) {
            $devices = @($after | ForEach-Object { $_.DeviceNames } | Select-Object -Unique)
            $entry.Correlated = @("$($devices.Count) device(s) reported a failure on this workload within $CorrelationHours hours of this change: $($devices -join ', '). This is timing only, not proof of cause.")
        }
    }

    $ordered = @($entries | Sort-Object @{ Expression = { if ($null -eq $_.Time) { [DateTimeOffset]::MinValue } else { $_.Time } }; Descending = $true })
    [PSCustomObject] @{
        PSTypeName            = 'IntuneAccess.ChangeTimeline'
        Entries               = $ordered
        ConfigurationChanges  = @($ordered | Where-Object Kind -EQ 'Configuration').Count
        OutcomeChanges        = @($ordered | Where-Object Kind -EQ 'Outcome').Count
        CorrelatedChanges     = @($ordered | Where-Object { @($_.Correlated).Count -gt 0 }).Count
        HiddenRefreshChanges  = $hiddenRefreshes
        CorrelationHours      = $CorrelationHours
        BaselineState         = if ($null -eq $SnapshotComparison) { 'NoBaseline' } else { 'Compared' }
        EvidenceBoundary      = 'Audit events show who changed what and when. Snapshot changes show what differs between two collections. A failure that follows a change is correlated in time only; it is not proof that the change caused it.'
        GeneratedAt           = $AsOf
    }
}