Files
scadaproj/components/audit/current-state/scadabridge/CURRENT-STATE.md
T

12 KiB

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 10-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:22AuditEvent 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<T> 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:12 builds export (:137, formats csv/jsonl/parquet) and verify-chain (:226). Hash-chain verify is currently a v1 no-op stubCLI/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/.
  • 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 10-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/ChannelAction/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; the canonical target is ../../spec/SPEC.md.