Modules/MDOP2.Audit.psm1

Set-StrictMode -Version Latest

# Append-only JSONL audit log. One file per month in the audit folder next to the entry script.
# Never pass secrets, tokens or message bodies; ConvertTo-MDOP2AuditLine also strips keys that look like them.

$script:Audit = $null

function Initialize-MDOP2Audit {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Directory,
        [Parameter(Mandatory)][string]$AdminUpn,
        [Parameter(Mandatory)][string]$TenantId
    )
    if (-not (Test-Path -LiteralPath $Directory)) { New-Item -ItemType Directory -Path $Directory -Force | Out-Null }
    $script:Audit = [pscustomobject]@{
        Directory = (Resolve-Path -LiteralPath $Directory).Path
        AdminUpn  = $AdminUpn
        TenantId  = $TenantId
        SessionId = [guid]::NewGuid().ToString()
    }
    $script:Audit
}

function Get-MDOP2AuditPath {
    param([datetime]$When = [datetime]::UtcNow)
    if (-not $script:Audit) { throw 'Audit log is not initialised.' }
    Join-Path $script:Audit.Directory ('audit-{0}.jsonl' -f $When.ToString('yyyy-MM'))
}

function Write-MDOP2Audit {
    <#
    .SYNOPSIS
    Appends one event to the audit log. Throws when the log can't be written, so actions never run unaudited.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('SessionStart', 'SessionEnd', 'Search', 'Action', 'View', 'Download')][string]$Type,
        [Collections.IDictionary]$Data = @{}
    )
    if (-not $script:Audit) { throw 'Audit log is not initialised.' }
    $now = [datetime]::UtcNow
    $event = [ordered]@{
        timestamp = $now.ToString('o')
        type      = $Type
        sessionId = $script:Audit.SessionId
        adminUpn  = $script:Audit.AdminUpn
        tenantId  = $script:Audit.TenantId
    }
    foreach ($k in $Data.Keys) { if (-not $event.Contains($k)) { $event[$k] = $Data[$k] } }
    $line = (ConvertTo-MDOP2AuditLine -Event $event) + [Environment]::NewLine
    $path = Get-MDOP2AuditPath -When $now
    # A named mutex keeps lines whole when several runspaces (the web server later) write at once
    $mutex = [Threading.Mutex]::new($false, 'Local\MDOP2-Audit')
    $held = $false
    try {
        try { $held = $mutex.WaitOne(10000) } catch [Threading.AbandonedMutexException] { $held = $true }
        if (-not $held) { throw 'Timed out waiting for the audit log lock.' }
        [IO.File]::AppendAllText($path, $line, [Text.UTF8Encoding]::new($false))
    }
    finally {
        if ($held) { $mutex.ReleaseMutex() }
        $mutex.Dispose()
    }
}

function Get-MDOP2AuditEvent {
    <#
    .SYNOPSIS
    Reads audit events back, newest file first. Lines that don't parse are skipped.
    #>

    [CmdletBinding()]
    param(
        [string]$Type,
        [string]$InternetMessageId,
        [string]$TenantId,
        [string]$Directory = $(if ($script:Audit) { $script:Audit.Directory })
    )
    if (-not $Directory -or -not (Test-Path -LiteralPath $Directory)) { return }
    foreach ($f in Get-ChildItem -LiteralPath $Directory -Filter 'audit-*.jsonl' | Sort-Object Name -Descending) {
        foreach ($l in [IO.File]::ReadLines($f.FullName)) {
            if (-not $l) { continue }
            try { $e = $l | ConvertFrom-Json } catch { continue }
            # Only whole events: a line cut short by a crash, or edited by hand, must not break the history page
            if ($e -isnot [Management.Automation.PSCustomObject]) { continue }
            $p = $e.PSObject.Properties
            if (-not $p['type'] -or -not $p['timestamp']) { continue }
            # The history and timeline pages sort on the timestamp, so it is always handed out as a UTC date
            $when = ConvertTo-MDOP2UtcTimestamp $e.timestamp
            if (-not $when) { continue }
            $e.timestamp = $when
            if ($Type -and $e.type -ne $Type) { continue }
            if ($TenantId -and -not ($p['tenantId'] -and $e.tenantId -eq $TenantId)) { continue }
            if ($InternetMessageId -and -not ($p['internetMessageId'] -and $e.internetMessageId -eq $InternetMessageId)) { continue }
            $e
        }
    }
}

function Get-MDOP2AuditContext {
    # The session this process writes as: directory, admin, tenant and session ID
    if (-not $script:Audit) { throw 'Audit log is not initialised.' }
    $script:Audit
}

Export-ModuleMember -Function Initialize-MDOP2Audit, Write-MDOP2Audit, Get-MDOP2AuditEvent, Get-MDOP2AuditContext