Files
git.stella-ops.org/scripts/audit-reconciliation.ps1
master 2e78085115 feat(audit): drop deprecated per-service audit tables + reconciliation (DEPRECATE-003)
Closes DEPRECATE-003 in SPRINT_20260408_005. Pre-release status means
the 30/90-day compat windows in the original Decision #5 are moot — no
external consumers. Decision #5 amended twice during session.

Drop migrations (embedded resources, auto-applied on startup per §2.7):
- authority.audit / authority.airgap_audit / authority.offline_kit_audit
  (002_drop_deprecated_audit_tables.sql)
- policy.audit (013; policy.gate_bypass_audit PRESERVED as domain evidence)
- notify.audit (008)
- scheduler.audit + partitions via CASCADE (009)
- proofchain.audit_log (004)

Kept by design:
- release_orchestrator.audit_entries + audit_sequences (hash chain, Decision #2)
- policy.gate_bypass_audit (domain evidence, unique query patterns)
- authority.login_attempts (auth protocol state, not audit)

Repository neutering — local DB write removed, Timeline emission preserved:
- PolicyAuditRepository.CreateAsync → Timeline-only; readers [Obsolete]
- NotifyAuditRepository.CreateAsync → Timeline-only; readers [Obsolete]
- PostgresSchedulerAuditService → removed INSERT, Timeline-only
- PostgresAttestorAuditSink.WriteAsync → no-op (endpoint-level .Audited()
  filter carries the audit signal)

Attestor cleanup:
- Deleted AuditLogEntity.cs
- Removed DbSet<AuditLogEntity> from ProofChainDbContext
- Removed LogAuditAsync / GetAuditLogAsync from IProofChainRepository
- Removed "audit_log" from SchemaIsolationService

Reconciliation tool substitutes for the 30-day wall-clock window:
- scripts/audit-reconciliation.ps1 joins each per-service audit table to
  timeline.unified_audit_events via the dual-write discriminator
  (details_jsonb.localAuditId / localEntryId) for deterministic pairs,
  tuple-matches Authority. Test-Table/to_regclass guards handle post-drop
  vacuous-pass. Overall PASS across pre/post/final runs.
- 4 reports under docs/qa/.

Sprint archivals:
- SPRINT_20260408_004 (Timeline unified audit sink) — all 7 tasks DONE
- SPRINT_20260408_005 (audit endpoint filter deprecation) — all 12 tasks DONE

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:03:02 +03:00

295 lines
11 KiB
PowerShell

#!/usr/bin/env pwsh
# Reconciles per-service audit tables against timeline.unified_audit_events.
# Substitutes for the 30-day observation window in
# SPRINT_20260408_004 (AUDIT-005) and SPRINT_20260408_005 (DEPRECATE-001).
#
# Dual-write emissions from each repository carry a discriminator in
# details_jsonb:
# - localAuditId (bigint) for policy/notify/scheduler
# - localEntryId (uuid) for release_orchestrator
# Authority's AuthorityAuditSink does not emit a localAuditId; it reconciles
# via tuple match (action = event_type, timestamp +/- 5s).
#
# Usage:
# powershell -File scripts/audit-reconciliation.ps1
# powershell -File scripts/audit-reconciliation.ps1 -OutFile docs/qa/audit-reconciliation-20260422.md
[CmdletBinding()]
param(
[string]$Container = "stellaops-postgres",
[string]$Database = "stellaops_platform",
[string]$DbUser = "stellaops",
[string]$OutFile = $null
)
$ErrorActionPreference = "Stop"
if (-not $OutFile) {
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$OutFile = Join-Path (Get-Location) "docs/qa/audit-reconciliation-$stamp.md"
}
function Invoke-Psql {
param([string]$Sql)
$result = $Sql | docker exec -i $Container psql -U $DbUser -d $Database -t -A -F "|" 2>&1
if ($LASTEXITCODE -ne 0) { throw "psql failed: $result" }
return $result
}
function Get-Count {
param([string]$Sql)
$r = Invoke-Psql -Sql $Sql
$line = ($r | Where-Object { $_ -match '^\d+$' } | Select-Object -First 1)
if (-not $line) { return 0 }
return [int]$line
}
function Test-Table {
# Returns $true iff the fully-qualified table (e.g. "policy.audit") exists.
param([string]$QualifiedName)
$r = Invoke-Psql -Sql "SELECT to_regclass('$QualifiedName') IS NOT NULL;"
return ($r -match '^t$')
}
# Each pair declares:
# Name human label
# LocalTable fully-qualified local audit table
# LocalIdCol column to extract for the join (bigserial / UUID)
# Modules list of module strings accepted in Timeline
# Discriminator JSON key in details_jsonb populated by the dual-write mapper
# DiscrimCast SQL cast applied to the JSON key before compare
$pairs = @(
@{
Name = "policy.audit <-> timeline(module=policy, localAuditId)"
LocalTable = "policy.audit"
LocalIdCol = "id"
Modules = @("policy")
Discriminator = "localAuditId"
DiscrimCast = "bigint"
},
@{
Name = "notify.audit <-> timeline(module=notify, localAuditId)"
LocalTable = "notify.audit"
LocalIdCol = "id"
Modules = @("notify")
Discriminator = "localAuditId"
DiscrimCast = "bigint"
},
@{
Name = "scheduler.audit <-> timeline(module=scheduler, localAuditId)"
LocalTable = "scheduler.audit"
LocalIdCol = "id"
Modules = @("scheduler")
Discriminator = "localAuditId"
DiscrimCast = "bigint"
},
@{
Name = "release_orchestrator.audit_entries <-> timeline(module IN (release,jobengine), localEntryId)"
LocalTable = "release_orchestrator.audit_entries"
LocalIdCol = "entry_id"
Modules = @("release", "jobengine")
Discriminator = "localEntryId"
DiscrimCast = "text"
}
)
Write-Host "Stella Ops audit-reconciliation" -ForegroundColor Cyan
Write-Host " Container: $Container"
Write-Host " Database : $Database"
Write-Host " Report : $OutFile"
Write-Host ""
$report = New-Object System.Collections.Generic.List[string]
$report.Add("# Audit Dual-Write Reconciliation Report")
$report.Add("")
$report.Add("- Generated: $(Get-Date -Format o)")
$report.Add("- Container: ``$Container``")
$report.Add("- Database : ``$Database``")
$report.Add("- Purpose : substitute for the 30-day wall-clock observation window described in SPRINT_20260408_004 (AUDIT-005) and SPRINT_20260408_005 (DEPRECATE-001). Each per-service audit table is reconciled against ``timeline.unified_audit_events`` using the discriminator the repository-level dual-write mapper puts into ``details_jsonb`` (``localAuditId`` / ``localEntryId``).")
$report.Add("")
$report.Add("## Headline counts")
$report.Add("")
$headlineTables = @(
"authority.audit",
"authority.login_attempts",
"policy.audit",
"notify.audit",
"scheduler.audit",
"release_orchestrator.audit_entries",
"timeline.unified_audit_events"
)
$report.Add("| Table | Rows |")
$report.Add("| --- | ---: |")
foreach ($tbl in $headlineTables) {
if (Test-Table -QualifiedName $tbl) {
$n = Get-Count "SELECT COUNT(*) FROM $tbl;"
$report.Add("| ``$tbl`` | $n |")
}
else {
$report.Add("| ``$tbl`` | _dropped (DEPRECATE-003)_ |")
}
}
$report.Add("")
$overallFail = $false
foreach ($p in $pairs) {
Write-Host "Reconciling: $($p.Name)"
$modulesSqlList = ($p.Modules | ForEach-Object { "'$_'" }) -join ","
$discExpr = "(t.details_jsonb->>'$($p.Discriminator)')::$($p.DiscrimCast)"
$localColExpr = "l.$($p.LocalIdCol)::$($p.DiscrimCast)"
if (-not (Test-Table -QualifiedName $p.LocalTable)) {
Write-Host " $($p.LocalTable) not found -- reclassified as dropped (DEPRECATE-003)."
$report.Add("## $($p.Name)")
$report.Add("")
$report.Add("Local table ``$($p.LocalTable)`` is **dropped** (SPRINT_20260408_005 / DEPRECATE-003). Timeline is the sole audit store; reconciliation is vacuous.")
$report.Add("")
$report.Add("| Metric | Value |")
$report.Add("| --- | ---: |")
$report.Add("| Status | **PASS (dropped)** |")
$report.Add("")
continue
}
$localCount = Get-Count "SELECT COUNT(*) FROM $($p.LocalTable);"
$dualWriteCnt = Get-Count @"
SELECT COUNT(*) FROM timeline.unified_audit_events t
WHERE t.module IN ($modulesSqlList)
AND t.details_jsonb ? '$($p.Discriminator)';
"@
# Data-loss direction: every local row must have a Timeline twin.
$missingInTimeline = Get-Count @"
SELECT COUNT(*) FROM $($p.LocalTable) l
WHERE NOT EXISTS (
SELECT 1 FROM timeline.unified_audit_events t
WHERE t.module IN ($modulesSqlList)
AND t.details_jsonb ? '$($p.Discriminator)'
AND $discExpr = $localColExpr
);
"@
# Informational: Timeline dual-write rows with no local twin (e.g. DB reset after emission).
$orphanInTimeline = Get-Count @"
SELECT COUNT(*) FROM timeline.unified_audit_events t
WHERE t.module IN ($modulesSqlList)
AND t.details_jsonb ? '$($p.Discriminator)'
AND NOT EXISTS (
SELECT 1 FROM $($p.LocalTable) l
WHERE $localColExpr = $discExpr
);
"@
$sampleMissing = Invoke-Psql @"
SELECT $($p.LocalIdCol)::text FROM $($p.LocalTable) l
WHERE NOT EXISTS (
SELECT 1 FROM timeline.unified_audit_events t
WHERE t.module IN ($modulesSqlList)
AND t.details_jsonb ? '$($p.Discriminator)'
AND $discExpr = $localColExpr
)
LIMIT 5;
"@
$pairStatus = if ($missingInTimeline -eq 0) { "PASS" } else { "FAIL" }
if ($pairStatus -ne "PASS") { $overallFail = $true }
$report.Add("## $($p.Name)")
$report.Add("")
$report.Add("| Metric | Value |")
$report.Add("| --- | ---: |")
$report.Add("| Local rows | $localCount |")
$report.Add("| Timeline dual-write rows (``details_jsonb ? '$($p.Discriminator)'``) | $dualWriteCnt |")
$report.Add("| **Missing in Timeline (data loss)** | $missingInTimeline |")
$report.Add("| Orphan in Timeline (local cleared post-emission) | $orphanInTimeline |")
$report.Add("| Status | **$pairStatus** |")
$report.Add("")
if ($missingInTimeline -gt 0 -and $sampleMissing) {
$report.Add("Sample missing ids (local, no Timeline twin):")
$report.Add("``````")
foreach ($id in $sampleMissing) { if ($id) { $report.Add($id) } }
$report.Add("``````")
$report.Add("")
}
Write-Host " local=$localCount dual-write=$dualWriteCnt missing=$missingInTimeline orphan=$orphanInTimeline $pairStatus"
}
# Authority: tuple-based, AuthorityAuditSink assigns a fresh GUID.
# Note: authority.login_attempts survives DEPRECATE-003 (it is auth protocol state,
# not an audit table). If it is ever dropped in a future sprint this block must
# be short-circuited, hence the same to_regclass guard used for the pairs above.
Write-Host "Reconciling: authority.login_attempts <-> timeline(module=authority) [tuple]"
if (-not (Test-Table -QualifiedName "authority.login_attempts")) {
$report.Add("## authority.login_attempts <-> timeline(module=authority) [tuple-match]")
$report.Add("")
$report.Add("Local table ``authority.login_attempts`` is **absent**. Reconciliation vacuous.")
$report.Add("")
$report.Add("| Metric | Value |")
$report.Add("| --- | ---: |")
$report.Add("| Status | **PASS (dropped)** |")
$report.Add("")
$authStatus = "PASS"
Write-Host " authority.login_attempts not found; skipping tuple reconciliation."
$overallStatus = if ($overallFail) { "FAIL" } else { "PASS" }
$report.Insert(3, "- **Overall status: $overallStatus**")
$report.Insert(4, "")
New-Item -ItemType Directory -Force -Path (Split-Path $OutFile -Parent) | Out-Null
$report -join "`n" | Set-Content -Path $OutFile -Encoding utf8
Write-Host ""
Write-Host "Report: $OutFile" -ForegroundColor Green
Write-Host "Overall: $overallStatus" -ForegroundColor $(if ($overallFail) { "Red" } else { "Green" })
if ($overallFail) { exit 1 } else { exit 0 }
}
$authLocal = Get-Count "SELECT COUNT(*) FROM authority.login_attempts;"
$authTimelineAll = Get-Count @"
SELECT COUNT(*) FROM timeline.unified_audit_events t
WHERE t.module = 'authority' AND t.id LIKE 'authority-%';
"@
$authMissing = Get-Count @"
SELECT COUNT(*) FROM authority.login_attempts l
WHERE NOT EXISTS (
SELECT 1 FROM timeline.unified_audit_events t
WHERE t.module = 'authority'
AND t.action = l.event_type
AND t.timestamp BETWEEN l.occurred_at - interval '5 seconds' AND l.occurred_at + interval '5 seconds'
);
"@
$authStatus = if ($authMissing -eq 0) { "PASS" } else { "FAIL" }
if ($authStatus -ne "PASS") { $overallFail = $true }
$report.Add("## authority.login_attempts <-> timeline(module=authority) [tuple-match]")
$report.Add("")
$report.Add("``AuthorityAuditSink`` assigns a fresh GUID for the Timeline id, so reconciliation falls back to tuple matching on ``(action=event_type, timestamp +/- 5s)``.")
$report.Add("")
$report.Add("| Metric | Value |")
$report.Add("| --- | ---: |")
$report.Add("| ``authority.login_attempts`` rows | $authLocal |")
$report.Add("| Timeline ``authority-*`` rows | $authTimelineAll |")
$report.Add("| **Local rows with no Timeline twin** | $authMissing |")
$report.Add("| Status | **$authStatus** |")
$report.Add("")
Write-Host " local=$authLocal timeline=$authTimelineAll missing=$authMissing $authStatus"
$overallStatus = if ($overallFail) { "FAIL" } else { "PASS" }
$report.Insert(3, "- **Overall status: $overallStatus**")
$report.Insert(4, "")
New-Item -ItemType Directory -Force -Path (Split-Path $OutFile -Parent) | Out-Null
$report -join "`n" | Set-Content -Path $OutFile -Encoding utf8
Write-Host ""
Write-Host "Report: $OutFile" -ForegroundColor Green
Write-Host "Overall: $overallStatus" -ForegroundColor $(if ($overallFail) { "Red" } else { "Green" })
if ($overallFail) { exit 1 }