# Audit β€” gaps & adoption backlog Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to reach the shared `ZB.MOM.WW.Audit` library. Status legend: β›” gap Β· 🟑 partial Β· βœ… matches. > **Adoption is deferred this round.** The library is being designed (shared contract in > [`shared-contract/ZB.MOM.WW.Audit.md`](shared-contract/ZB.MOM.WW.Audit.md)) but is not yet > wired into any app β€” exactly where `ZB.MOM.WW.Auth` and `ZB.MOM.WW.Theme` sit today. > The items below are the follow-on work; each lands as a separate PR per project. ## Divergence vs spec ### Β§1 Canonical record (`AuditEvent`) | Canonical field | OtOpcUa | MxAccessGateway | ScadaBridge | |---|---|---|---| | `EventId` (Guid, required) | βœ… β€” idempotency key; buffer key + filtered-unique DB index | β›” β€” no event key; only an `AUTOINCREMENT` rowid (`AuditId`) | βœ… β€” direct | | `OccurredAtUtc` (DateTimeOffset, required) | 🟑 β€” `DateTime` UTC; widen at mapping boundary | 🟑 β€” `DateTimeOffset` but store-assigned (not caller-supplied); direct after widening | 🟑 β€” `DateTime` UTC-forced; widen at mapping boundary | | `Actor` (string, required) | βœ… β€” direct (`AuditEvent.Actor` β†’ `ConfigAuditLog.Principal`) | 🟑 β€” `KeyId` nullable; keyless events (`init-db`/`list-keys`) need a `"system"`/`"cli"` fallback | 🟑 β€” nullable on system-originated rows; fallback needed | | `Action` (string, required) | 🟑 β€” `Action` field exists, but persisted as `"{Category}:{Action}"` composite in `EventType`; canonical keeps them separate | βœ… β€” `EventType` literal direct | 🟑 β€” derived as `{Channel}.{Kind}` (e.g. `ApiOutbound.ApiCall`) | | `Outcome` (AuditOutcome, required) | β›” **NEW** β€” derived from `EventType` vocabulary; not stored today | β›” **NEW** β€” derived: `constraint-denied`β†’`Denied`, else `Success` | β›” **NEW** β€” derived from `Status` (+`InboundAuthFailure` Kindβ†’`Denied`) | | `Category` (string?) | βœ… β€” `AuditEvent.Category` (e.g. `"Config"`) | β›” β€” no field; constant `"ApiKey"` at mapping | βœ… β€” `Channel` | | `Target` (string?) | β›” β€” no dedicated field; closest is `DetailsJson` | β›” β€” embedded in `Details` text (`commandKind`/`target`) | βœ… β€” direct | | `SourceNode` (string?) | βœ… β€” `SourceNode` (logical cluster node / host name, NOT an OPC UA NodeId) | 🟑 β€” `RemoteAddress`; dashboard path only (null on CLI/constraint paths) | βœ… β€” direct | | `CorrelationId` (Guid?) | βœ… β€” direct (`CorrelationId.Value`) | β›” β€” not captured today; left null | βœ… β€” direct | | `DetailsJson` (string?) | βœ… β€” direct (JSON CHECK constraint enforced) | 🟑 β€” `Details` is a plain string, not JSON; wrap or store as-is | 🟑 β€” ~15 rich/plumbing fields serialize here at the cross-project reporting boundary | ### Β§2 `IAuditWriter` seam | | OtOpcUa | MxAccessGateway | ScadaBridge | |---|---|---|---| | Named seam | β›” β€” no `IAuditWriter`; `AuditWriterActor` is the sink, consumed directly via Akka messaging | β›” β€” `IApiKeyAuditStore` (narrow, two-method) is the seam; no general `IAuditWriter` | βœ… β€” `IAuditWriter` with `WriteAsync(AuditEvent, CancellationToken)` signature; "failures must NEVER abort the user-facing action" contract; best-effort | | Best-effort / never throws | 🟑 β€” the actor drops a failed flush (best-effort), but the seam is not a typed interface a caller can inject independently | β›” β€” no contract; `AppendAsync` may propagate | βœ… | | Record type at the seam | 🟑 β€” OtOpcUa's own `AuditEvent` (8 fields, with Commons value-types `NodeId`/`CorrelationId`) | β›” β€” `ApiKeyAuditEntry` (4 fields) | 🟑 β€” ScadaBridge's ~25-field `AuditEvent` (rich record; adoption = keep own record, adopt canonical interface name + `AuditOutcome`) | ### Β§3 `IAuditRedactor` seam | | OtOpcUa | MxAccessGateway | ScadaBridge | |---|---|---|---| | Named seam | β›” β€” no redactor; no payload filtering today | β›” β€” no redactor; safety by construction (entry type cannot carry a secret) | βœ… β€” `IAuditPayloadFilter` (`AuditEvent Apply(AuditEvent)`, pure/never-throws/over-redacts); **only the name differs** from canonical `IAuditRedactor` | | Over-redacts on failure | β›” β€” n/a | β›” β€” n/a | βœ… β€” `SafeDefaultAuditPayloadFilter` is the reference | ### Β§4 `AuditOutcome` β€” the new normalized field `Outcome` is a **genuinely new field** across all three projects. No app stores it today; each encodes it implicitly. All three must derive and emit it at adoption: β†’ **Gap O1 (OtOpcUa):** derive from `EventType` vocabulary β€” `OpcUaAccessDenied` / `CrossClusterNamespaceAttempt` β†’ `Denied`; config-write verbs β†’ `Success`. No `Failure` value exists in OtOpcUa's vocabulary today (failed flushes are dropped, not emitted), so OtOpcUa will produce only `Success` / `Denied` until/unless failure events are added. β†’ **Gap O2 (MxGateway):** derive β€” `constraint-denied` β†’ `Denied`; all others β†’ `Success`. No `Failure` events are emitted today. β†’ **Gap O3 (ScadaBridge):** derive from `AuditStatus` β€” `Delivered` β†’ `Success`; `Failed` / `Parked` / `Discarded` β†’ `Failure`; `Kind = InboundAuthFailure` β†’ `Denied`. In-flight states (`Submitted` / `Forwarded` / `Attempted`) collapse to the last-known terminal state when projecting; `Skipped` is excluded from the canonical projection. ### Β§5 `Actor` β†’ Auth principal At adoption, every emit site should supply the `ZB.MOM.WW.Auth` principal as `Actor` (string). The library carries no Auth dependency β€” `Actor` is a plain `string` β€” but the handshake with Auth is the semantic goal (closes the loop). β†’ **Gap P1 (all 3):** at adoption, update emit sites to populate `Actor` from the Auth principal (LDAP user / API-key name). Auth adoption (#8 in `components/auth/GAPS.md`) is a prerequisite for the full story; until then, use the existing actor string. ### Β§6 OtOpcUa two-producer problem OtOpcUa has **two writers to `ConfigAuditLog`**: the structured Akka `AuditEvent` path AND older SQL stored procedures that `INSERT` directly (bare `EventType`, NULL `EventId` / `CorrelationId`, populated `ClusterId` / `GenerationId`). Normalization targets the structured path only; the SP path stays per-project. β†’ **Gap Q1 (OtOpcUa):** decide at adoption whether to route SP events through the actor or leave them non-idempotent. Also: the `ClusterId`-filter / actor-never-sets-`ClusterId` mismatch (Admin UI `ClusterAudit.razor` filters by `ClusterId`, but the actor path sets `NodeId` not `ClusterId`, so structured rows are invisible to the cluster view). Fix when normalizing the query surface. ## Adoption backlog (ordered) | # | Item | Projects | Priority | Effort | Risk | Notes | |---|---|---|---|---|---|---| | 1 | **OtOpcUa:** rename `AuditWriterActor` β†’ implements `IAuditWriter`; replace `Commons/Messages/Audit/AuditEvent.cs` with canonical record; add `Outcome` derivation at every emit site (Gap O1) | OtOpcUa | Med | M | Med | Actor internals (batching / dedup / flush triggers) stay bespoke; only the seam type and record change. Commons value-types `NodeId`/`CorrelationId` bridged at construction. | | 2 | **MxGateway:** map `IApiKeyAuditStore` / `ApiKeyAuditEntry` / `ApiKeyAuditRecord` β†’ `IAuditWriter` / `AuditEvent`; generate `EventId` per write; add `"system"`/`"cli"` Actor fallback; constant `Category = "ApiKey"`; `constraint-denied`β†’`Outcome.Denied` (Gaps O2, record gaps) | MxGateway | Low | S | Med | ⚠ **COORDINATE** β€” a parallel session is editing this repo for the MELβ†’Serilog migration (Health/Telemetry normalization). Do NOT start until the Serilog session has landed (or is explicitly fenced off); the two efforts share `Security/Authentication/` DI wiring. | | 3 | **ScadaBridge:** rename `IAuditPayloadFilter` β†’ `IAuditRedactor` (or alias during transition); adopt canonical `AuditOutcome` enum (Gap O3); confirm writer contract matches (already byte-for-byte) | ScadaBridge | Low | S | High | **"Align, don't replace."** Blast radius is HIGH β€” `IAuditPayloadFilter` is used across the entire pipeline (site, central, wiring). Rename + alias only; no transport/storage/record change. `DefaultAuditPayloadFilter` / `SafeDefaultAuditPayloadFilter` implementations unchanged. | | 4 | **All:** populate `Actor` from `ZB.MOM.WW.Auth` principal at emit sites (Gap P1) | All 3 | Low | S | Low | **Prerequisite:** Auth adoption per `components/auth/GAPS.md` #8. Until Auth is adopted, leave the existing actor string as-is. | | 5 | **OtOpcUa:** reconcile two-producer problem β€” decide SP path routing + fix `ClusterId`-filter / actor mismatch in `ClusterAudit.razor` (Gap Q1) | OtOpcUa | Low | S | Low | Normalization does not unify the SP path; this is a reconcile item to decide and document. The mismatch means structured `AuditEvent` rows are currently invisible to the cluster-scoped view. | | 6 | **MxGateway:** add `CorrelationId` capture at constraint denial + dashboard paths; structured `Target` from `Details` text (currently embedded as a plain string in `ConstraintEnforcer`) | MxGateway | Low | S | Low | Nice-to-have parity; not required for adoption. `CorrelationId` and `Target` canonical fields left null until this is done. | **Sequencing:** #3 (ScadaBridge rename) is lowest-risk and self-contained β€” do it first (or last, depending on blast-radius appetite). #1 (OtOpcUa) is medium effort but independent; it can start once the shared library is built. #2 (MxGateway) is the smallest code change but has the highest **coordination dependency** β€” gate it on the Serilog migration landing first. #4 (Actorβ†’Auth) is blocked on Auth adoption and is the last to close. #5 and #6 are cleanup items with no bearing on shared-library adoption. Each adoption lands as an opt-in version bump per project behind the seam; the shared library is consumed but the bespoke transport/storage/UI for each project is not touched. ## Decisions still open - ScadaBridge `IAuditPayloadFilter` β†’ `IAuditRedactor`: outright rename vs. transitional alias (both are valid; alias reduces blast radius in the short term). - MxGateway `Details` plain string β†’ `DetailsJson`: store as-is or wrap in a JSON object at the mapping boundary. - `AuditOutcome` column in OtOpcUa storage: add a new `Outcome` column to `ConfigAuditLog` or fold into `DetailsJson` / derive at read time (schema change vs. runtime cost). - OtOpcUa SP path: route through the actor path (unified producer) or leave as a bespoke secondary writer with its own column conventions (separate reconcile effort).