# Proposed shared library: `ZB.MOM.WW.Audit` A contract on paper — the public surface to extract so the three projects stop re-implementing audit-event capture with incompatible shapes. Realizes [`../spec/SPEC.md`](../spec/SPEC.md). **Not yet created.** Reference implementations already exist: ScadaBridge's `IAuditWriter`/`IAuditPayloadFilter` (already at target shape), mxaccessgw structured-log audit trail, OtOpcUa admin-UI audit log. ## Package (.NET 10) ``` ZB.MOM.WW.Audit # the single package: event record, seams, helpers, DI wiring ``` Single package, single DLL. Only non-BCL dependency: `Microsoft.Extensions.DependencyInjection.Abstractions` (for `AddZbAudit`). Published to the Gitea NuGet feed; SemVer. | Package (→ DLL) | Transitive deps | OtOpcUa | mxaccessgw | ScadaBridge | |---|---|---|---|---| | `ZB.MOM.WW.Audit` | `Microsoft.Extensions.DependencyInjection.Abstractions` | ✅ | ✅ | ✅ | All three auth-bearing processes are .NET 10 — the x86/net48 mxaccessgw worker does no audit emission, so net48 multi-targeting is **not** required. ## `AuditEvent` record and `AuditOutcome` enum ```csharp public sealed record AuditEvent { public required Guid EventId { get; init; } public required DateTimeOffset OccurredAtUtc { get; init; } // normalized to UTC on assignment public required string Actor { get; init; } public required string Action { get; init; } public required AuditOutcome Outcome { get; init; } public string? Category { get; init; } public string? Target { get; init; } public string? SourceNode { get; init; } public Guid? CorrelationId { get; init; } public string? DetailsJson { get; init; } } public enum AuditOutcome { Success, Failure, Denied } ``` `OccurredAtUtc` is the only field with a normalization contract: any value assigned is coerced to UTC (via `ToUniversalTime()`). All other fields are caller-supplied and carried through without transformation by the library internals. ## Seams ### `IAuditWriter` ```csharp public interface IAuditWriter { Task WriteAsync(AuditEvent evt, CancellationToken ct = default); } ``` **Hard contract:** - Best-effort delivery. The implementation **MUST swallow all internal failures** and **MUST NOT throw** to the caller. A write that fails silently is preferable to a write that crashes the calling thread or kills a request pipeline. - `CancellationToken` is respected for cooperative cancellation but a cancellation does not constitute a contract violation; the implementation may choose to complete a partially-written event anyway. ### `IAuditRedactor` ```csharp public interface IAuditRedactor { AuditEvent Apply(AuditEvent rawEvent); } ``` **Hard contract:** - Pure function (no I/O, no side effects). - **MUST NOT throw.** On any internal failure the implementation must over-redact (e.g. replace the affected field with a sentinel such as `"[redacted]"`) rather than propagate the exception. Lossier output is always preferable to a thrown exception reaching the caller. ## Shipped helpers (concrete) ### Redactors | Type | Behaviour | |---|---| | `NullAuditRedactor` | Identity — returns the event unchanged. Registered as the default by `AddZbAudit`. | | `TruncatingAuditRedactor` | Caps `DetailsJson` and `Target` to a configurable maximum length and appends a marker (e.g. `"…"`) when truncated. Never throws. Configured via `TruncatingAuditRedactorOptions`. | | `TruncatingAuditRedactorOptions` | Options record for `TruncatingAuditRedactor`: `MaxDetailsJsonLength`, `MaxTargetLength`, `TruncationMarker`. | ### Writers | Type | Behaviour | |---|---| | `NoOpAuditWriter` | Discards every event. Registered as the default by `AddZbAudit`; consumer replaces with a real writer. | | `CompositeAuditWriter` | Fan-out: forwards each event to an ordered list of inner `IAuditWriter` instances. A failing inner writer is swallowed (per the `IAuditWriter` contract) — it does **not** abort the remaining writers in the list. | | `RedactingAuditWriter` | Decorator: calls `IAuditRedactor.Apply` on the event, then delegates the redacted event to an inner `IAuditWriter`. Separates the redaction concern from any concrete writer. | ## DI wiring ```csharp public static IServiceCollection AddZbAudit(this IServiceCollection services); ``` Registers defaults via `TryAdd` so any prior consumer registration wins: - `IAuditRedactor` → `NullAuditRedactor` (singleton) - `IAuditWriter` → `NoOpAuditWriter` (singleton) A consumer that registers its own `IAuditWriter` (e.g. a Serilog-backed writer or a `CompositeAuditWriter`) before or after calling `AddZbAudit` will see its registration respected. `AddZbAudit` does **not** clear or override existing registrations. ## Relationship to Telemetry (`ILogRedactor`) `IAuditRedactor` mirrors Telemetry.Serilog's `ILogRedactor` in shape and naming — same single-method contract, same "pure, must not throw, over-redact on failure" semantics — so that a future `ZB.MOM.WW.Hosting` aggregator package can wire both behind a single configuration surface without an impedance mismatch. `ZB.MOM.WW.Audit` has **no dependency** on `ZB.MOM.WW.Telemetry` or any Serilog package. The alignment is intentional design convergence; the independence is a hard boundary. ## What stays in each consumer OtOpcUa: admin-UI audit sink (Blazor event handler → `IAuditWriter`), `Category` constants specific to OPC UA operations. mxaccessgw: gRPC interceptor that captures actor/action from call metadata; constraint-aware `Category` tagging; `DetailsJson` serialization of gateway-specific payloads. ScadaBridge: site-scoped `SourceNode` population; `ManagementActor` enforcement callbacks; `IAuditPayloadFilter` → `IAuditRedactor` migration (shape is already equivalent — adoption is a near-zero-effort rename). ## Open contract questions 1. **Batching**: a `WriteBatchAsync(IEnumerable, CancellationToken)` overload on `IAuditWriter` may be warranted once a database-backed writer is in use. Defer until the first consumer demonstrates the need; batching can be added without breaking the existing single-event surface. 2. **Structured `DetailsJson`**: confirm whether callers should supply raw JSON strings or whether a typed `TDetails` generic overload (serialized internally) is cleaner. The current `string?` keeps the library dependency-free but shifts serialization to the caller. 3. **`CompositeAuditWriter` error policy**: decide whether per-writer failure should be observable (e.g. an optional `ILogger`) or always silently dropped. Logging the failure is diagnostic-friendly but adds a logging dependency. See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.