Files
personaEngine2/src/Audit/Write-PersonaAuditRecord.ps1
T
dave cdc6bb33d3 Implement Stage A: rule engine, validation, audit, and safety gates
Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).

  354 offline Pester tests      PASS
  Engine purity (Principle IV)  PASS
  Sanitization (SC-013)         PASS  (156 files)
  Graph module loaded in tests  none  (SC-008 holds)

What landed
  - Four-layer configuration validation with stable finding codes, covering
    every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
  - Run loop, audit records (NDJSON through a single sink), summaries,
    reconciliation, and exit codes 0-6
  - Persistence behind a single write-body builder whose result always has
    exactly one key
  - Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
  - Six docs, two pipelines, traceability matrix, V-5a and sanitization records

Three deviations from tasks.md, each recorded in its status block

  T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
  state and the rule engine is pure; a counter there would break Principle IV.
  It lives in New-PersonaRunCounter and is applied in the run loop.

  A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
  imports the manifest, which requires Microsoft.Graph.Authentication, so a
  loop living only inside it could not run on a machine without the Graph SDK
  and SC-004 could not be proven at all. The entry script is now a thin
  wrapper and what ships is what is tested.

  The invalid-config corpus is generated by a committed script, with the
  generated fixtures committed too, so a reviewer sees the fixture in the diff.

Defects found by running the code, not by reading it

  Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
  around a comma-returned array collapsed every membership list into one
  bogus space-joined entry. That is a silent false non-match, exactly what
  FR-013 exists to prevent.

  A 403 whose status appears only in the exception message parsed as $null,
  which the retry policy treats as a transport error - five requests per
  account against a tenant already refusing. Status extraction now falls back
  to the message text, bounded to 400-599.

  The sanitization scan walked tracked files only, so it covered 34 of 156
  files and none of this phase's code. It now scans untracked non-ignored
  files too, and a negative control confirms it catches a planted leak.

  Test-Json reports one error per violating location, not first-failure-only
  as the V-5a draft claimed. Record and pin corrected.

Enforcement remains blocked on the V-4 security sign-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:48:19 -04:00

109 lines
4.0 KiB
PowerShell

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
}
}