public/Get-MsecEntraPimActivation.ps1

function Get-MsecEntraPimActivation {
    <#
    .SYNOPSIS
        Privileged Identity Management activity from the directory audit log - who activated
        which privileged role, for how long, with what justification, and whether MFA was used.

    .DESCRIPTION
        The record of privileged access actually being USED, as opposed to who holds it.
        Get-MsecEntraRoleHolder answers "who could be an administrator"; this answers "who was
        one, when, and why".

        ACTIVATION IS NOT THE ONLY THING HERE. loggedByService 'PIM' covers the whole lifecycle:
        activations and their expiry, eligible assignments added and removed, role settings
        changed, and PIM's own alerts being resolved. AuditType says which. -ActivationsOnly
        narrows to activations when that is the question, and the rest is left in by default
        because "someone changed a PIM role setting" belongs in the same timeline as "someone
        activated the role it governs".

        MFA IS $null WHEN IT WAS NOT RECORDED, NEVER $false. IsAuthenticatedWithMfa is present
        on activation records and absent on most others. Defaulting the absence to false would
        report every role-setting change as an unauthenticated action, and would bury the
        finding that actually matters - an activation that genuinely carried no MFA - in noise
        that is merely unrecorded. Filtering with -WithoutMfa therefore matches $false only.

        BOTH PIM SCOPES ARE RETURNED. Entra directory roles and Azure resource roles both log
        here, distinguished by Category: 'RoleManagement' is the directory, 'ResourceManagement'
        is Azure RBAC. Measured on one tenant over 30 days: 62 directory activations and 54
        Azure resource activations - dropping either would halve the picture, so Scope is a
        column rather than a filter applied silently.

        JUSTIFICATION IS FREE TEXT AND MAY BE ABSENT. Where PIM does not require one, the field
        is simply missing, which is not the same as a user declining to give one - the role
        setting decides. Read an empty Justification alongside Get-MsecEntraPimPolicy, which
        says whether that role demanded it.

        THE WINDOW IS THE AUDIT LOG'S, NOT PIM'S. Directory audit retention is 30 days on Entra
        ID P1/P2 and 7 days without, so -Days is capped at 30 and a tenant without P1 will
        return less than asked for without saying so - that limit is the tenant's licence, not
        this command.

    .PARAMETER Days
        How far back to look. Default 7, maximum 30 - the directory audit retention ceiling.

    .PARAMETER ActivationsOnly
        Only role activations, excluding expiry, eligibility changes and setting changes.

    .PARAMETER WithoutMfa
        Only records where MFA was explicitly NOT used. Records that never carried the field
        are excluded - see the description.

    .PARAMETER NoJustification
        Only records with no justification text.

    .PARAMETER Role
        Limit to one or more role display names. Case-insensitive, matched as a substring so
        'Administrator' catches every administrator role.

    .EXAMPLE
        Get-MsecEntraPimActivation -Days 30 -ActivationsOnly |
            Group-Object Actor, Role | Sort-Object Count -Descending

        Who activates what, and how often.

    .EXAMPLE
        Get-MsecEntraPimActivation -Days 30 -ActivationsOnly -WithoutMfa

        Privileged roles activated without multifactor authentication. An empty result is a
        good answer and worth reporting as one.

    .EXAMPLE
        Get-MsecEntraPimActivation -Days 30 -ActivationsOnly -Role 'Global Administrator' |
            Select-Object ActivityDateTime, Actor, Justification, DurationHours, IpAddress

        Every Global Administrator activation, with the reason given.

    .EXAMPLE
        Get-MsecEntraPimActivation -Days 30 | Where-Object AuditType -eq 'ResolveAlert'

        PIM's own alerts being resolved - the only trace of them that reaches any log, since
        PIM alerts do not flow to Defender XDR or Sentinel.

    .OUTPUTS
        PSCustomObject per audit record, PSTypeName 'MsecEntraPimActivation'.

    .NOTES
        Needs 'AuditLog.Read.All', which the msec app already holds. Pairs with
        Get-MsecEntraPimPolicy (what activation demands) and Get-MsecEntraRoleHolder (who holds
        the role at all).
    #>

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

        [switch]   $ActivationsOnly,
        [switch]   $WithoutMfa,
        [switch]   $NoJustification,
        [string[]] $Role
    )

    Assert-MsecSession

    $since = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
    $path  = "/v1.0/auditLogs/directoryAudits?`$filter=loggedByService eq 'PIM' and activityDateTime ge $since"

    $records = $null
    try { $records = @(Invoke-MsecGraphRequest -Path $path -All) }
    catch {
        if ($_.Exception.Message -match '403|Forbidden') {
            throw "Forbidden reading the directory audit log. The msec app needs 'AuditLog.Read.All' (admin consent required). Original error: $($_.Exception.Message)"
        }
        throw
    }

    foreach ($r in $records) {
        # additionalDetails is a key/value list, not an object - the useful fields all live
        # here and none of them are top-level.
        $detail = @{}
        foreach ($d in @($r.additionalDetails)) {
            if ($d.key) { $detail[[string]$d.key] = [string]$d.value }
        }

        $auditType = $detail['AuditType']
        if ($ActivationsOnly -and $auditType -ne 'ActivateRole') { continue }

        # The first target resource is the role; the rest are tenant and principal shells.
        $roleName = @($r.targetResources | ForEach-Object { [string]$_.displayName } | Where-Object { $_ }) |
                    Select-Object -First 1

        if ($Role) {
            $match = $false
            foreach ($want in $Role) { if ($roleName -and $roleName -like "*$want*") { $match = $true; break } }
            if (-not $match) { continue }
        }

        # $null, NOT $false, when the field is absent. An absent value means "not recorded on
        # this kind of record", and reporting that as "no MFA" would bury the real finding.
        $mfa = if ($detail.ContainsKey('IsAuthenticatedWithMfa')) {
                   $detail['IsAuthenticatedWithMfa'] -in @('True', 'true', '1')
               } else { $null }
        if ($WithoutMfa -and $mfa -ne $false) { continue }

        $justification = if ($detail['Justification']) { $detail['Justification'] } else { $null }
        if ($NoJustification -and $justification) { continue }

        $start  = $null; $expiry = $null
        $parsed = [datetime]::MinValue
        if ($detail['StartTime']      -and [datetime]::TryParse($detail['StartTime'],      [ref] $parsed)) { $start  = $parsed }
        $parsed = [datetime]::MinValue
        if ($detail['ExpirationTime'] -and [datetime]::TryParse($detail['ExpirationTime'], [ref] $parsed)) { $expiry = $parsed }

        # $null rather than 0 where either end is missing: an activation whose window could not
        # be read is not an activation that lasted no time.
        $duration = if ($start -and $expiry) { [math]::Round(($expiry - $start).TotalHours, 1) } else { $null }

        [PSCustomObject]@{
            PSTypeName        = 'MsecEntraPimActivation'
            ActivityDateTime  = if ($r.activityDateTime) { [datetime]$r.activityDateTime } else { $null }
            Activity          = [string]$r.activityDisplayName
            # ActivateRole, ResolveAlert, and the eligibility/setting types. The clearest way
            # to tell one kind of PIM record from another.
            AuditType         = $auditType
            # RoleManagement = Entra directory roles. ResourceManagement = Azure RBAC.
            Scope             = [string]$r.category
            Actor             = @($r.initiatedBy.user.userPrincipalName, $r.initiatedBy.app.displayName) |
                                Where-Object { $_ } | Select-Object -First 1
            Role              = $roleName
            Result            = [string]$r.result
            ResultReason      = [string]$r.resultReason
            # Absent where the role never demanded one - see Get-MsecEntraPimPolicy.
            Justification     = $justification
            StartTime         = $start
            ExpirationTime    = $expiry
            DurationHours     = $duration
            # $null = not recorded on this record type. $false = genuinely no MFA.
            AuthenticatedWithMfa = $mfa
            ApprovalRequired  = if ($detail.ContainsKey('IsActivationRequireApproval')) {
                                    $detail['IsActivationRequireApproval'] -in @('True', 'true', '1')
                                } else { $null }
            IpAddress         = $detail['ipaddr']
            UserAgent         = $detail['UserAgent']
            RoleTemplateId    = $detail['TemplateId']
            CorrelationId     = [string]$r.correlationId
        }
    }
}