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>
295 lines
11 KiB
PowerShell
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 }
|