Files
scadaproj/components/audit/shared-contract/ZB.MOM.WW.Audit.md
T
2026-06-01 07:08:31 -04:00

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.
  • 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

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:

  • IAuditRedactorNullAuditRedactor (singleton)
  • IAuditWriterNoOpAuditWriter (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; IAuditPayloadFilterIAuditRedactor migration (shape is already equivalent — adoption is a near-zero-effort rename).

Open contract questions

  1. Batching: a WriteBatchAsync(IEnumerable<AuditEvent>, 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<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.