diff --git a/components/audit/current-state/scadabridge/CURRENT-STATE.md b/components/audit/current-state/scadabridge/CURRENT-STATE.md new file mode 100644 index 0000000..a64a4e8 --- /dev/null +++ b/components/audit/current-state/scadabridge/CURRENT-STATE.md @@ -0,0 +1,162 @@ +# Audit — current state: ScadaBridge + +Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET; solution `ZB.MOM.WW.ScadaBridge.slnx`. +Audit code centers on the dedicated `ZB.MOM.WW.ScadaBridge.AuditLog` project, with the shared +record + seams living in `ZB.MOM.WW.ScadaBridge.Commons`. All paths relative to repo root. +Verified 2026-06-01. + +**By far the largest audit implementation in the family** — a full who-did-what pipeline +across a site SQLite hot-path and a central MS SQL store, with forwarding, reconciliation, +purge, partition maintenance, redaction, CLI export, hash-chain verify (v1 stub), and a Blazor +UI. **Key finding: ScadaBridge is already at the target.** It already has an `IAuditWriter` +best-effort seam (near-identical to the canonical contract) and an `IAuditPayloadFilter` +redaction seam (= the library's `IAuditRedactor`, just renamed). Adoption is *align, don't +replace* — mostly naming alignment; the enormous transport/storage/CLI/UI stays bespoke. + +## 1. How it works today + +### The record — `AuditEvent` (~25 fields) + +`src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs:22` — a `sealed record`, +append-only, "single source of truth for AuditLog (#23) rows." Far richer than the canonical +8-field event. Notable fields: + +- Identity / correlation: `EventId` (idempotency key, `:25`), `CorrelationId` (per-op + lifecycle, `:68`), `ExecutionId` (per-run, `:75`), `ParentExecutionId` (spawner link, `:82`). +- Classification: `Channel` (`:62`), `Kind` (`:65`), `Status` (`:109`) — the domain enums (below). +- Provenance: `SourceSiteId` (`:85`), `SourceNode` (`:94`, stamped from `INodeIdentityProvider`), + `SourceInstanceId` (`:97`), `SourceScript` (`:100`), `Actor` (`:103`), `Target` (`:106`). +- Outcome detail: `HttpStatus` (`:112`), `DurationMs` (`:115`), `ErrorMessage` (`:118`), + `ErrorDetail` (`:121`). +- Payload: `RequestSummary` / `ResponseSummary` (truncated+redacted, `:124`/`:127`), + `PayloadTruncated` (`:130`), `Extra` (free-form JSON, `:133`). +- Lifecycle plumbing: `IngestedAtUtc` (null on site, stamped at central ingest, `:52`), + `ForwardState` (site-only, null on central, `:136`). + +**UTC-forcing init-setters.** `OccurredAtUtc` (`:39`) and `IngestedAtUtc` (`:52`) keep a backing +field and call `DateTime.SpecifyKind(value, DateTimeKind.Utc)` on assignment, so a value built +from a literal or rehydrated from a SQL Server `datetime2` column (which strips `Kind` on the +wire) cannot leak downstream as `Unspecified`/local. The record uses `DateTime` (not +`DateTimeOffset`) deliberately, to match the partitioned `datetime2` column shape (`:9-21`). + +### Domain vocabulary — four enums + +`src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/`: + +- `AuditChannel.cs:7` — trust boundary crossed: `ApiOutbound`, `DbOutbound`, `Notification`, + `ApiInbound`. +- `AuditKind.cs:8` — specific event within a channel: `ApiCall`, `ApiCallCached`, `DbWrite`, + `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, + `CachedSubmit`, `CachedResolve`. Cached variants emit multiple rows per operation. +- `AuditStatus.cs:8` — lifecycle status of the row: `Submitted`, `Forwarded`, `Attempted`, + `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`. +- `AuditForwardState.cs:9` — site-local forwarding state (central rows leave null): `Pending`, + `Forwarded`, `Reconciled`. The site retention purge MUST NOT drop a `Pending` row. + +### The writer seam — `IAuditWriter` (best-effort, never aborts the action) + +`src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs:10` — boundary-side +abstraction: `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)` (`:18`). The +contract is explicit and matches the canonical seam almost word-for-word: **"Failures must NEVER +abort the user-facing action"** (`:8`), best-effort, "implementations must swallow/log internal +failures rather than propagating them to the calling boundary code" (`:13-14`). + +### The redaction seam — `IAuditPayloadFilter` (pure, never throws) + +`src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs:22` — `AuditEvent Apply( +AuditEvent rawEvent)` (`:30`). Filters an event between construction and persistence: +truncates oversized payloads, redacts headers/body/SQL params, sets `PayloadTruncated`. +**Pure function** returning a filtered COPY via `with` expressions, and **MUST NOT throw** — +on internal failure it over-redacts and increments the `AuditRedactionFailure` health metric +(`:11-20`, `:26-28`). This is exactly the canonical `IAuditRedactor` under a different name. +Two implementations: `DefaultAuditPayloadFilter.cs:56` (full truncation + header/body/SQL +redaction with live options) and `SafeDefaultAuditPayloadFilter.cs:19` (always-safe fallback — +header-only redaction, over-redacts on parse failure, `:42-59`). + +### Transport / storage / pipeline — stays per-project + +The `ZB.MOM.WW.ScadaBridge.AuditLog` project is split into `Site/`, `Central/`, `Payload/`, and +`Configuration/`. This is the bespoke half and is **not** a candidate for extraction; cited here +only to show the scale around the common core: + +- **Site hot-path:** `Site/SqliteAuditWriter.cs:32` (`IAuditWriter` over an owned `SqliteConnection` + fed by a bounded `Channel` drained on a background task, so script-thread callers never block + on disk I/O; first-write-wins on duplicate `EventId`). `Site/FallbackAuditWriter.cs:28` composes + the SQLite writer with a drop-oldest `RingBufferFallback` so a primary failure never bubbles out. + `Site/Telemetry/` forwards rows to central over Akka `ClusterClient`. +- **Central ingest/store:** `Central/CentralAuditWriter.cs:40` (`ICentralAuditWriter`, direct MS SQL + write for central-originated events, per-call EF scope, idempotent `InsertIfNotExistsAsync`, + swallows every exception per "alog.md §13"). `Central/AuditLogIngestActor.cs:46` batches site + telemetry; `Central/SiteAuditReconciliationActor.cs:68` periodically pulls to catch dropped + forwards; `Central/AuditLogPurgeActor.cs:58` enforces retention; `Central/AuditLogPartitionMaintenanceService.cs:55` + manages the partitioned table. +- **CLI:** `CLI/Commands/AuditCommands.cs:21` builds `export` (`:137`, formats `csv`/`jsonl`/`parquet`) + and `verify-chain` (`:226`). Hash-chain verify is currently a **v1 no-op stub** — + `CLI/Commands/AuditVerifyChainHelpers.cs:6-10` ("v1 is a no-op"). +- **UI:** Blazor pages under `CentralUI/Components/Pages/Audit/` (e.g. `AuditLogPage.razor:1`, + gated by `[Authorize(Policy = AuthorizationPolicies.OperationalAudit)]`) plus drill-down + components in `CentralUI/Components/Audit/`. +- **Wiring:** `AuditLog/ServiceCollectionExtensions.cs:59` `AddAuditLog(...)`, `:316` + `AddAuditLogCentralMaintenance(...)`. + +## 2. Mapping to the canonical record + +Target (`ZB.MOM.WW.Audit`, being built): `record AuditEvent { Guid EventId; DateTimeOffset +OccurredAtUtc; string Actor; string Action; AuditOutcome Outcome; string? Category; string? +Target; string? SourceNode; Guid? CorrelationId; string? DetailsJson; }`. ScadaBridge's record is +a strict superset — the canonical fields map directly; the rich extras collapse into `DetailsJson`. + +| Canonical field | ScadaBridge source | Notes | +|---|---|---| +| `EventId` (Guid) | `AuditEvent.EventId` | Direct; same idempotency-key role. | +| `OccurredAtUtc` (DateTimeOffset) | `AuditEvent.OccurredAtUtc` (`DateTime`, UTC-forced) | Type bridge `DateTime`(Utc)↔`DateTimeOffset`; semantics identical. | +| `Actor` (string) | `AuditEvent.Actor` (nullable) | Direct; ScadaBridge allows null (system-originated rows). | +| `Action` (string) | `AuditEvent.Kind` (+`Channel`) | Derive a stable action string, e.g. `{Channel}.{Kind}` (`ApiOutbound.ApiCall`). | +| `Outcome` (Success/Failure/Denied) | `AuditEvent.Status` | `Delivered`→Success; `Failed`/`Parked`/`Discarded`→Failure; `InboundAuthFailure`(Kind)→Denied; in-flight `Submitted`/`Forwarded`/`Attempted` collapse to the last-known terminal state when projecting. | +| `Category` (string?) | `AuditEvent.Channel` | The coarse bucket; pairs with `Action` above. | +| `Target` (string?) | `AuditEvent.Target` | Direct. | +| `SourceNode` (string?) | `AuditEvent.SourceNode` | Direct (`node-a`/`central-b`/…). | +| `CorrelationId` (Guid?) | `AuditEvent.CorrelationId` | Direct (per-op lifecycle id). | +| `DetailsJson` (string?) | `ExecutionId`, `ParentExecutionId`, `SourceSiteId`, `SourceInstanceId`, `SourceScript`, `HttpStatus`, `DurationMs`, `ErrorMessage`, `ErrorDetail`, `RequestSummary`, `ResponseSummary`, `PayloadTruncated`, `Extra`, `IngestedAtUtc`, `ForwardState` | The ~15 rich/plumbing fields serialize into the canonical `DetailsJson` extension. | + +The canonical record is a lossy *projection* of ScadaBridge's — fine for cross-project +reporting, but ScadaBridge keeps its full record as the storage shape (the partitioned SQL +schema, forwarding state, and reconciliation all depend on the extra columns). + +## 3. Adoption plan → `ZB.MOM.WW.Audit` + +**Posture: align, don't replace.** ScadaBridge is the reference implementation the shared +library is being extracted *from*; it already has both seams. Adoption is mostly renaming and +contract-confirmation, with a deliberately small touched surface and a large blast radius if +done carelessly. **Priority: LOW. Blast radius: HIGH.** + +**Align (small, naming-level):** +- **Rename the redaction seam to match the contract.** `IAuditPayloadFilter` → adopt + `ZB.MOM.WW.Audit.IAuditRedactor` (`AuditEvent Apply(AuditEvent)` — identical signature and + pure/never-throws contract). Either alias `IAuditPayloadFilter : IAuditRedactor` during + transition or rename outright; `DefaultAuditPayloadFilter` / `SafeDefaultAuditPayloadFilter` + implement it unchanged. See [`../../shared-contract/`](../../shared-contract/). +- **Confirm the writer contract matches.** `IAuditWriter.WriteAsync(AuditEvent, CancellationToken + = default)` is already byte-for-byte the canonical signature, and the "never abort the + user-facing action" wording matches. The only delta is the **record type**: the library's + `IAuditWriter` is typed on the *canonical* 8-field `AuditEvent`, while ScadaBridge's is typed on + its ~25-field record. Resolve by either (a) keeping ScadaBridge's writer on its own rich record + and adopting only the library's *interface name + outcome enum*, or (b) having the shared seam be + generic over the event type. **Recommended: (a)** — adopt the canonical `AuditOutcome` enum and + the interface naming, but keep the bespoke `AuditEvent` as ScadaBridge's storage record, since the + whole transport/partition/forwarding layer is built on its extra columns. (Best-practice fit: this + is the minimal-coupling option — share the contract, not the schema.) + +**Keep bespoke (the large, untouched majority):** +- The entire `Site/` (SQLite hot-path + ring-buffer fallback + telemetry forwarder) and `Central/` + (ingest / reconcile / purge / partition maintenance) pipeline. +- The `AuditEvent` rich record itself, the four domain enums (`AuditChannel`/`AuditKind`/ + `AuditStatus`/`AuditForwardState`), CLI `export`/`verify-chain`, and the Blazor audit UI. +- The redaction *policy* (`DefaultAuditPayloadFilter` options, per-target overrides) — only the + interface name is shared, not the implementation. + +**Net:** ScadaBridge converges by renaming one interface and adopting the canonical `AuditOutcome` +enum + the `Kind`/`Channel`→`Action`/`Category` and `…`→`DetailsJson` projection for any +cross-project reporting. No transport, storage, CLI, or UI is replaced. Sequencing and the +cross-project gap list live in [`../../GAPS.md`](../../GAPS.md); the canonical target is +[`../../spec/SPEC.md`](../../spec/SPEC.md).