6.6 KiB
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.
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
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
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.
CancellationTokenis 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
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
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
- Batching: a
WriteBatchAsync(IEnumerable<AuditEvent>, CancellationToken)overload onIAuditWritermay 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. - Structured
DetailsJson: confirm whether callers should supply raw JSON strings or whether a typedTDetailsgeneric overload (serialized internally) is cleaner. The currentstring?keeps the library dependency-free but shifts serialization to the caller. CompositeAuditWritererror policy: decide whether per-writer failure should be observable (e.g. an optionalILogger<CompositeAuditWriter>) or always silently dropped. Logging the failure is diagnostic-friendly but adds a logging dependency.
See ../GAPS.md for the adoption order and effort/risk.