Files
personaEngine2/src/Audit/Write-PersonaAuditRecord.ps1
T

109 lines
4.0 KiB
PowerShell
Raw Normal View History

function Write-PersonaAuditRecord {
<#
.SYNOPSIS
The single emission point for audit records (FR-022, OTD-006).
.DESCRIPTION
Serializes one record as newline-delimited JSON and emits it to file, to the
object stream, or both, per logging.destination.
Every audit record in the engine passes through here. That is the whole
design: adding a transport - an approved logging platform, an event hub, a
different file layout - is a change to this function and nothing else. If
call sites wrote their own output, each new transport would mean auditing
every call site again, and the one that got missed would be silent.
Emission failure never ends the run. A full disk or a locked file is an
operational problem with the audit sink, not a reason to abandon a
classification run mid-population and leave the directory in a half-reconciled
state. The failure is surfaced as a warning, once, and processing continues.
.PARAMETER Record
An ordered dictionary from New-PersonaAuditRecord.
.PARAMETER Destination
file, stream, both, or none.
.PARAMETER Path
Output file for the file and both destinations.
.PARAMETER State
Optional sink state carrying the one-warning latch, so a failing sink warns
once per run rather than once per user.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[object] $Record,
[ValidateSet('file', 'stream', 'both', 'none')]
[string] $Destination = 'stream',
[string] $Path,
[object] $State
)
process {
if ($Destination -eq 'none') { return }
if ($Destination -in @('stream', 'both')) {
# The Information stream, not the success stream. Audit records emitted
# onto the success stream would be indistinguishable from a function's
# return value: the run loop returns its outcome there, and mixing the two
# would turn one object into an array of several thousand.
#
# The record object is emitted, not a string, so a caller capturing it
# with -InformationVariable can assert on fields without reparsing.
Write-Information -MessageData $Record -Tags 'PersonaEngine.Audit'
}
if ($Destination -in @('file', 'both')) {
if (-not $Path) {
Write-Warning 'logging.destination requests file output but no path is configured. No audit file was written.'
return
}
try {
$line = $Record | ConvertTo-Json -Depth 16 -Compress
$directory = Split-Path -Parent $Path
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
$null = New-Item -ItemType Directory -Path $directory -Force
}
# Append, one record per line. UTF-8 without BOM so the file is
# machine-readable by any NDJSON consumer.
Add-Content -LiteralPath $Path -Value $line -Encoding utf8NoBOM -ErrorAction Stop
}
catch {
if ($null -ne $State -and $State.FileSinkFailed) { return }
if ($null -ne $State) { $State.FileSinkFailed = $true }
Write-Warning "Audit file sink failed; the run continues without file output: $($_.Exception.Message)"
}
}
}
}
function New-PersonaAuditSinkState {
<#
.SYNOPSIS
Creates the per-run sink state for Write-PersonaAuditRecord.
.DESCRIPTION
Holds the latch that keeps a failing file sink from emitting one warning per
user. A run over five thousand accounts with a locked log file should warn
once, not five thousand times, or the warning that matters is buried in the
noise it generates.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param()
[pscustomobject]@{
PSTypeName = 'PersonaEngine.AuditSinkState'
FileSinkFailed = $false
}
}