docs(audit): shared-contract ZB.MOM.WW.Audit
This commit is contained in:
@@ -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<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`](../GAPS.md) for the adoption order and effort/risk.
|
||||
Reference in New Issue
Block a user