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 |