diff --git a/components/audit/shared-contract/ZB.MOM.WW.Audit.md b/components/audit/shared-contract/ZB.MOM.WW.Audit.md new file mode 100644 index 0000000..a120bff --- /dev/null +++ b/components/audit/shared-contract/ZB.MOM.WW.Audit.md @@ -0,0 +1,153 @@ +# 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.