Files
scadaproj/docs/plans/2026-06-01-zb-mom-ww-audit-shared-library.md
T

975 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Audit Normalization Component + ZB.MOM.WW.Audit Shared Library — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Normalize the "audit" cross-cutting concern across the three sister repos (OtOpcUa, MxAccessGateway, ScadaBridge) under `components/audit/`, then build a thin, tested, packed `ZB.MOM.WW.Audit` shared library that extracts the genuinely-common core: a canonical `AuditEvent` record, an `AuditOutcome` enum, a best-effort `IAuditWriter` seam, an `ILogRedactor`-aligned `IAuditRedactor` seam, and a handful of dependency-light helpers.
**Architecture:** Spec-first. Phase A writes the normalization docs (the spec drives the API). Phase B builds the single NuGet package, strictly TDD, one type per task. Phase C registers the component in the workspace indexes. **No sister-repo adoption this round** — adoption is captured in `GAPS.md` exactly where Auth/Theme/Health adoption sits today. Transport/storage (Akka / SQLite-hot-path+central-SQL / SQLite) stays per-project; only the record + two seams are shared.
**Tech Stack:** .NET 10, C# (file-scoped namespaces, `sealed record`, nullable enabled, central package management), xUnit + coverlet, `dotnet pack`. The library's only non-BCL dependency is `Microsoft.Extensions.DependencyInjection.Abstractions` (for the `AddZbAudit` extension). Design doc: [`docs/plans/2026-06-01-audit-component-design.md`](2026-06-01-audit-component-design.md).
**Conventions mirrored from `ZB.MOM.WW.Auth/`** (read these for reference): `Directory.Build.props` (net10.0, `Version` 0.1.0, central package mgmt), `Directory.Packages.props`, `.slnx` solution, `build/pack.sh` (`dotnet pack -c Release -o ./artifacts`), per-package pack metadata (`IsPackable`/`PackageId`/`Authors=ZB.MOM.WW`/`Description`/`PackageProjectUrl`/`RepositoryUrl`), test csproj (`IsPackable=false`, `<Using Include="Xunit"/>`, coverlet collector). Repo URL: `https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit`.
---
## Phase A — `components/audit/` normalization docs (spec-first)
> Doc tasks: no TDD. Each task's "verification" is the file exists, cross-links resolve, and the content matches the cited code. Tasks 02 are independent (different sister repos) and dispatchable in parallel. Capture **real `file:line` refs** — re-verify against current code, do not trust this plan's line numbers blindly.
### Task 0: Current-state — OtOpcUa
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2
**Files:**
- Create: `components/audit/current-state/otopcua/CURRENT-STATE.md`
**Source to read (code-verify, capture `file:line`):**
- `~/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs` — record (`EventId, Category, Action, Actor, OccurredAtUtc, DetailsJson, SourceNode, CorrelationId`).
- `~/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs` — EF entity + filtered-unique `EventId` index.
- `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs` — cluster-singleton, batch 500 / 5s flush, two-layer dedup.
- `~/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs`; `~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor`.
**Step 1:** Write the doc with sections: *How it works today* (record shape, transport = Akka broadcast → singleton, storage = `ConfigAuditLog`, dedup/idempotency, scope = config writes + authz checks), *Mapping to the canonical record* (per the design's mapping table — `Action`/`Category`/`SourceNode`/`CorrelationId` map directly; `Outcome` is derived from the action vocabulary; rich fields → `DetailsJson`), and an *Adoption plan* (`AuditEvent`→canonical record; `AuditWriterActor : IAuditWriter`; `ConfigAuditLog` mapping — medium effort).
**Step 2:** Verify every `file:line` ref by opening the cited file. Fix any drift.
**Step 3: Commit**
```bash
git add components/audit/current-state/otopcua/CURRENT-STATE.md
git commit -m "docs(audit): current-state OtOpcUa"
```
### Task 1: Current-state — MxAccessGateway
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 0, Task 2
**Files:**
- Create: `components/audit/current-state/mxaccessgw/CURRENT-STATE.md`
**Source to read (code-verify, capture `file:line`):**
- `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs` (`AppendAsync` + `ListRecentAsync`).
- `.../ApiKeyAuditRecord.cs` (`AuditId, KeyId, EventType, RemoteAddress, CreatedUtc, Details`), `.../ApiKeyAuditEntry.cs`, `.../SqliteApiKeyAuditStore.cs`.
**Step 1:** Write the doc: *How it works today* (narrowest of the three — API-key auth events/denials only, SQLite append + list-recent, no separate redaction seam — scrubbing is in the store), *Mapping to canonical* (`KeyId``Actor`, `EventType``Action`, `CreatedUtc``OccurredAtUtc`, denials→`Outcome.Denied`, `Category="ApiKey"`, `RemoteAddress``SourceNode`, `Details``DetailsJson`; new `EventId` Guid needed), *Adoption plan* (map `IApiKeyAuditStore`/`ApiKeyAuditRecord``IAuditWriter`/`AuditEvent` — low effort, but **coordinate**: the parallel health/observability session is already editing this repo for MEL→Serilog).
**Step 2:** Verify refs.
**Step 3: Commit**
```bash
git add components/audit/current-state/mxaccessgw/CURRENT-STATE.md
git commit -m "docs(audit): current-state MxAccessGateway"
```
### Task 2: Current-state — ScadaBridge
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 1
**Files:**
- Create: `components/audit/current-state/scadabridge/CURRENT-STATE.md`
**Source to read (code-verify, capture `file:line`):**
- `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs` (~25 fields, UTC-forcing init-setters).
- `.../Commons/Interfaces/Services/IAuditWriter.cs` (best-effort: "Failures must NEVER abort the user-facing action").
- `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs` (pure, never-throws, over-redacts).
- The `ZB.MOM.WW.ScadaBridge.AuditLog/` Site (SQLite hot-path) + Central (MS SQL ingest/reconcile/purge/partition-maintenance) tree; `Commons/Types/Enums/{AuditChannel,AuditKind,AuditStatus,AuditForwardState}.cs`.
**Step 1:** Write the doc: *How it works today* (the largest implementation — full who-did-what across site+central, hash-chain verify, CLI + UI + export). **State the key finding plainly: ScadaBridge is already at the target** — its `IAuditWriter` and `IAuditPayloadFilter` contracts are near-identical to what the library extracts. *Mapping to canonical* (`Kind`(+`Channel`)→`Action`/`Category` strings, `Status``Outcome`, rich fields → `DetailsJson`). *Adoption plan*: "align, don't replace" — rename `IAuditPayloadFilter``IAuditRedactor`, keep its `IAuditWriter`. Large surface, mostly naming alignment; low priority / high blast-radius.
**Step 2:** Verify refs.
**Step 3: Commit**
```bash
git add components/audit/current-state/scadabridge/CURRENT-STATE.md
git commit -m "docs(audit): current-state ScadaBridge"
```
### Task 3: SPEC.md + EVENT-MODEL.md
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (synthesizes Tasks 02)
**Files:**
- Create: `components/audit/spec/SPEC.md`
- Create: `components/audit/spec/EVENT-MODEL.md`
**Step 1:** Write `SPEC.md` — the ONE normalized target. Open with **Section 0: Normalized vs left-per-project** (normalized = the canonical record + `Outcome` + the two seams; left-per-project = transport/storage, domain vocab `Channel`/`Kind`/`Status`/`ForwardState` + OtOpcUa event-types, query/CLI/UI, each app's redaction *policy*). Then: the best-effort writer contract, the never-throw redactor contract, and the "audit closes the loop on Auth" hinge (`Actor` SHOULD be the `ZB.MOM.WW.Auth` principal; kept as a string in the contract).
**Step 2:** Write `EVENT-MODEL.md` (the reference doc, mirroring auth's `CANONICAL-ROLES.md` / theme's `DESIGN-TOKENS.md`): the field-by-field canonical record (required core + optional + `DetailsJson`), the `AuditOutcome` definition (`Success | Failure | Denied`) with which app states map to each, and the full per-project mapping table from the design doc.
**Step 3: Commit**
```bash
git add components/audit/spec/SPEC.md components/audit/spec/EVENT-MODEL.md
git commit -m "docs(audit): spec + event-model"
```
### Task 4: shared-contract/ZB.MOM.WW.Audit.md
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocked by Task 3)
**Files:**
- Create: `components/audit/shared-contract/ZB.MOM.WW.Audit.md`
**Step 1:** Document the proposed public API *on paper* (this is the contract Phase B implements): the single package `ZB.MOM.WW.Audit`; the `AuditEvent` record + `AuditOutcome` enum signatures; `IAuditWriter` and `IAuditRedactor` interfaces with their hard contracts; the shipped helpers (`NullAuditRedactor`, `TruncatingAuditRedactor` + options, `NoOpAuditWriter`, `CompositeAuditWriter`, `RedactingAuditWriter`); `AddZbAudit`. State the single dependency (`Microsoft.Extensions.DependencyInjection.Abstractions`) and the "aligned-but-independent" relationship to Telemetry's `ILogRedactor`.
**Step 2: Commit**
```bash
git add components/audit/shared-contract/ZB.MOM.WW.Audit.md
git commit -m "docs(audit): shared-contract ZB.MOM.WW.Audit"
```
### Task 5: README.md + GAPS.md
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocked by Tasks 3, 4)
**Files:**
- Create: `components/audit/README.md`
- Create: `components/audit/GAPS.md`
**Step 1:** `README.md` — overview + per-project status table linking into the spec/current-state/shared-contract docs (match auth's/ui-theme's README shape).
**Step 2:** `GAPS.md` — per-project divergences vs `SPEC.md` + the adoption/extraction backlog with priority/effort/risk: ScadaBridge "align, don't replace" (low priority, high blast-radius); MxGateway map onto seam (low effort, **coordinate** — shared repo with the other session); OtOpcUa record+actor migration (medium); cross-cutting `Outcome` normalization, `Actor`=Auth-principal, `IAuditRedactor` naming aligned with `ILogRedactor`. Note adoption is deferred (not built this round).
**Step 3: Commit**
```bash
git add components/audit/README.md components/audit/GAPS.md
git commit -m "docs(audit): README + GAPS adoption backlog"
```
---
## Phase B — `ZB.MOM.WW.Audit` library (TDD)
> All Phase B tasks blocked by Task 4 (the API on paper). Build from `~/Desktop/scadaproj/ZB.MOM.WW.Audit/`. Run tests with `dotnet test` from that root.
### Task 6: Scaffold the library + test project + solution
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 4)
**Files:**
- Create: `ZB.MOM.WW.Audit/Directory.Build.props`
- Create: `ZB.MOM.WW.Audit/Directory.Packages.props`
- Create: `ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.slnx`
- Create: `ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj`
- Create: `ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj`
- Create: `ZB.MOM.WW.Audit/build/pack.sh`
- Create: `ZB.MOM.WW.Audit/README.md`
**Step 1: `Directory.Build.props`** (copy auth's):
```xml
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
```
**Step 2: `Directory.Packages.props`:**
```xml
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Extensions -->
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<!-- Test -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>
```
**Step 3: `src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj`:**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Audit</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Canonical audit event model + best-effort writer and redactor seams for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>
```
**Step 4: `tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj`:**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Audit\ZB.MOM.WW.Audit.csproj" />
</ItemGroup>
</Project>
```
**Step 5: `ZB.MOM.WW.Audit.slnx`:**
```xml
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj" />
</Folder>
</Solution>
```
**Step 6: `build/pack.sh`** (`chmod +x` it):
```bash
#!/usr/bin/env bash
# pack.sh — produce the ZB.MOM.WW.Audit NuGet package into ./artifacts.
set -euo pipefail
dotnet pack -c Release -o ./artifacts
```
**Step 7: `README.md`** — package overview + consumer matrix (all three apps; adoption deferred) + versioning note, mirroring auth's README shape.
**Step 8: Verify it builds (empty but valid):**
Run: `cd ~/Desktop/scadaproj/ZB.MOM.WW.Audit && dotnet build ZB.MOM.WW.Audit.slnx`
Expected: Build succeeded (0 errors).
**Step 9: Commit**
```bash
cd ~/Desktop/scadaproj/ZB.MOM.WW.Audit
git init -q 2>/dev/null || true
git add -A && git commit -m "chore(audit): scaffold ZB.MOM.WW.Audit solution"
```
> Note: this library is its own nested git repo (like `ZB.MOM.WW.Auth/`). Commit from inside `ZB.MOM.WW.Audit/`.
### Task 7: AuditEvent record + AuditOutcome enum + the two seam interfaces
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 6)
> High-risk: this is the data contract every consumer maps onto. The interfaces are trivial (no behaviour) and are defined here so the helpers can compile; the record is TDD'd.
**Files:**
- Create: `src/ZB.MOM.WW.Audit/AuditOutcome.cs`
- Create: `src/ZB.MOM.WW.Audit/AuditEvent.cs`
- Create: `src/ZB.MOM.WW.Audit/IAuditWriter.cs`
- Create: `src/ZB.MOM.WW.Audit/IAuditRedactor.cs`
- Test: `tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs`
**Step 1: Write the failing tests** (`AuditEventTests.cs`):
```csharp
namespace ZB.MOM.WW.Audit.Tests;
public class AuditEventTests
{
private static AuditEvent Minimal() => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "alice",
Action = "ConfigPublished",
Outcome = AuditOutcome.Success,
};
[Fact]
public void Required_core_fields_round_trip()
{
var id = Guid.NewGuid();
var evt = Minimal() with { EventId = id, Actor = "svc", Action = "ApiCall", Outcome = AuditOutcome.Denied };
Assert.Equal(id, evt.EventId);
Assert.Equal("svc", evt.Actor);
Assert.Equal("ApiCall", evt.Action);
Assert.Equal(AuditOutcome.Denied, evt.Outcome);
}
[Fact]
public void OccurredAtUtc_is_normalized_to_utc()
{
var local = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5));
var evt = Minimal() with { OccurredAtUtc = local };
Assert.Equal(TimeSpan.Zero, evt.OccurredAtUtc.Offset);
Assert.Equal(local.UtcDateTime, evt.OccurredAtUtc.UtcDateTime);
}
[Fact]
public void Optional_fields_default_to_null()
{
var evt = Minimal();
Assert.Null(evt.Category);
Assert.Null(evt.Target);
Assert.Null(evt.SourceNode);
Assert.Null(evt.CorrelationId);
Assert.Null(evt.DetailsJson);
}
[Fact]
public void Records_with_same_values_are_equal()
{
var id = Guid.NewGuid();
var when = DateTimeOffset.UtcNow;
AuditEvent Make() => new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
Assert.Equal(Make(), Make());
}
}
```
**Step 2: Run to verify failure**
Run: `cd ~/Desktop/scadaproj/ZB.MOM.WW.Audit && dotnet test`
Expected: FAIL — `AuditEvent`/`AuditOutcome` do not exist (compile error).
**Step 3: Implement.** `AuditOutcome.cs`:
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>Normalized outcome of an audited action.</summary>
public enum AuditOutcome
{
/// <summary>The action completed successfully.</summary>
Success,
/// <summary>The action failed due to an error.</summary>
Failure,
/// <summary>The action was rejected by authentication/authorization.</summary>
Denied,
}
```
`AuditEvent.cs`:
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Canonical, transport-agnostic audit record — who did what, when, with what outcome.
/// Required core + optional common fields + a <see cref="DetailsJson"/> extension bag. Each
/// sister app maps its own record onto this; domain vocabularies (channels/kinds/event-types)
/// map into <see cref="Action"/>/<see cref="Category"/>/<see cref="DetailsJson"/> and are not
/// modelled here. See scadaproj/components/audit/spec/EVENT-MODEL.md.
/// </summary>
public sealed record AuditEvent
{
/// <summary>Idempotency key uniquely identifying this audit event.</summary>
public required Guid EventId { get; init; }
/// <summary>When the audited action occurred. Normalized to UTC on assignment.</summary>
public required DateTimeOffset OccurredAtUtc
{
get => _occurredAtUtc;
init => _occurredAtUtc = value.ToUniversalTime();
}
private readonly DateTimeOffset _occurredAtUtc;
/// <summary>Who performed the action (identity string; the ZB.MOM.WW.Auth principal at adoption).</summary>
public required string Actor { get; init; }
/// <summary>What was done — a verb/event-type string.</summary>
public required string Action { get; init; }
/// <summary>Normalized outcome.</summary>
public required AuditOutcome Outcome { get; init; }
/// <summary>Optional subsystem/grouping for the action.</summary>
public string? Category { get; init; }
/// <summary>Optional target of the action (resource/method/connection).</summary>
public string? Target { get; init; }
/// <summary>Optional node that emitted the event.</summary>
public string? SourceNode { get; init; }
/// <summary>Optional correlation id joining this row to its originating request/workflow.</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Optional JSON extension carrying project-specific fields.</summary>
public string? DetailsJson { get; init; }
}
```
`IAuditWriter.cs`:
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Best-effort sink for <see cref="AuditEvent"/>s. Implementations MUST swallow/log internal
/// failures rather than propagating them — a failed audit write must never abort the
/// user-facing action that produced it.
/// </summary>
public interface IAuditWriter
{
/// <summary>Persist an audit event. Best-effort; must not throw to the caller.</summary>
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
}
```
`IAuditRedactor.cs`:
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Filters an <see cref="AuditEvent"/> between construction and persistence — truncates oversized
/// fields and scrubs sensitive content. Pure function: returns a filtered COPY and MUST NOT throw
/// (over-redact on internal failure). Shaped to mirror Telemetry's <c>ILogRedactor</c> so a future
/// ZB.MOM.WW.Hosting aggregator can wire both consistently; intentionally has no dependency on it.
/// </summary>
public interface IAuditRedactor
{
/// <summary>Apply the configured truncation/redaction policy and return a filtered copy.</summary>
AuditEvent Apply(AuditEvent rawEvent);
}
```
**Step 4: Run to verify pass**
Run: `dotnet test`
Expected: PASS (4 tests).
**Step 5: Commit** (from `ZB.MOM.WW.Audit/`):
```bash
git add -A && git commit -m "feat(audit): AuditEvent record + AuditOutcome + writer/redactor seams"
```
### Task 8: NullAuditRedactor
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 9, Task 10, Task 11, Task 12
**Files:**
- Create: `src/ZB.MOM.WW.Audit/NullAuditRedactor.cs`
- Test: `tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs`
**Step 1: Failing test:**
```csharp
namespace ZB.MOM.WW.Audit.Tests;
public class NullAuditRedactorTests
{
[Fact]
public void Apply_returns_input_unchanged()
{
var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "{\"k\":1}" };
Assert.Same(evt, NullAuditRedactor.Instance.Apply(evt));
}
}
```
**Step 2: Run — FAIL** (`NullAuditRedactor` not defined). `dotnet test`.
**Step 3: Implement:**
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>Identity redactor — returns the event unchanged. The default when no policy is configured.</summary>
public sealed class NullAuditRedactor : IAuditRedactor
{
/// <summary>Shared singleton instance.</summary>
public static readonly NullAuditRedactor Instance = new();
private NullAuditRedactor() { }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent) => rawEvent;
}
```
**Step 4: Run — PASS.** `dotnet test`.
**Step 5: Commit:** `git add -A && git commit -m "feat(audit): NullAuditRedactor"`
### Task 9: TruncatingAuditRedactor + options
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8, Task 10, Task 11, Task 12
**Files:**
- Create: `src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs`
- Create: `src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs`
- Test: `tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs`
**Step 1: Failing tests:**
```csharp
namespace ZB.MOM.WW.Audit.Tests;
public class TruncatingAuditRedactorTests
{
private static AuditEvent Evt(string? details, string? target = null) => new()
{
EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success,
DetailsJson = details, Target = target,
};
[Fact]
public void Short_values_pass_through_unchanged()
{
var r = new TruncatingAuditRedactor(new() { MaxDetailsJsonLength = 100 });
var evt = Evt("small");
Assert.Equal("small", r.Apply(evt).DetailsJson);
}
[Fact]
public void Oversized_details_are_truncated_with_marker()
{
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 10, TruncationMarker = "~" };
var r = new TruncatingAuditRedactor(opts);
var result = r.Apply(Evt(new string('x', 50)));
Assert.Equal(10, result.DetailsJson!.Length);
Assert.EndsWith("~", result.DetailsJson);
}
[Fact]
public void Oversized_target_is_truncated()
{
var r = new TruncatingAuditRedactor(new() { MaxTargetLength = 5, TruncationMarker = "" });
var result = r.Apply(Evt(null, target: "abcdefghij"));
Assert.Equal(5, result.Target!.Length);
}
[Fact]
public void Null_fields_are_left_null()
{
var r = new TruncatingAuditRedactor();
var result = r.Apply(Evt(null));
Assert.Null(result.DetailsJson);
Assert.Null(result.Target);
}
}
```
**Step 2: Run — FAIL.** `dotnet test`.
**Step 3: Implement.** `TruncatingAuditRedactorOptions.cs`:
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>Caps for <see cref="TruncatingAuditRedactor"/>.</summary>
public sealed class TruncatingAuditRedactorOptions
{
/// <summary>Max length of <see cref="AuditEvent.DetailsJson"/> before truncation. Default 4096.</summary>
public int MaxDetailsJsonLength { get; set; } = 4096;
/// <summary>Max length of <see cref="AuditEvent.Target"/> before truncation. Default 512.</summary>
public int MaxTargetLength { get; set; } = 512;
/// <summary>Marker appended to a truncated value. Default "…[truncated]".</summary>
public string TruncationMarker { get; set; } = "…[truncated]";
}
```
`TruncatingAuditRedactor.cs`:
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Redactor that caps oversized <see cref="AuditEvent.DetailsJson"/> and <see cref="AuditEvent.Target"/>.
/// Never throws — over-redacts (drops DetailsJson) on internal failure. The secret-field policy
/// (which fields are sensitive) stays per-project; compose this with a project redactor as needed.
/// </summary>
public sealed class TruncatingAuditRedactor : IAuditRedactor
{
private readonly TruncatingAuditRedactorOptions _options;
/// <summary>Creates the redactor with the given options (defaults when null).</summary>
public TruncatingAuditRedactor(TruncatingAuditRedactorOptions? options = null)
=> _options = options ?? new TruncatingAuditRedactorOptions();
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
try
{
return rawEvent with
{
Target = Truncate(rawEvent.Target, _options.MaxTargetLength),
DetailsJson = Truncate(rawEvent.DetailsJson, _options.MaxDetailsJsonLength),
};
}
catch
{
// Hard contract: never throw. Over-redact on internal failure.
return rawEvent with { DetailsJson = null };
}
}
private string? Truncate(string? value, int max)
{
if (value is null || value.Length <= max) return value;
var marker = _options.TruncationMarker;
if (marker.Length >= max) return marker[..max];
return string.Concat(value.AsSpan(0, max - marker.Length), marker);
}
}
```
**Step 4: Run — PASS.** `dotnet test`.
**Step 5: Commit:** `git add -A && git commit -m "feat(audit): TruncatingAuditRedactor + options"`
### Task 10: NoOpAuditWriter
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 8, Task 9, Task 11, Task 12
**Files:**
- Create: `src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs`
- Test: `tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs`
**Step 1: Failing test:**
```csharp
namespace ZB.MOM.WW.Audit.Tests;
public class NoOpAuditWriterTests
{
[Fact]
public async Task WriteAsync_completes_without_error()
{
var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
await NoOpAuditWriter.Instance.WriteAsync(evt);
}
}
```
**Step 2: Run — FAIL.** `dotnet test`.
**Step 3: Implement:**
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>Writer that discards events. Default when audit is disabled, and useful in tests.</summary>
public sealed class NoOpAuditWriter : IAuditWriter
{
/// <summary>Shared singleton instance.</summary>
public static readonly NoOpAuditWriter Instance = new();
private NoOpAuditWriter() { }
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
}
```
**Step 4: Run — PASS.** `dotnet test`.
**Step 5: Commit:** `git add -A && git commit -m "feat(audit): NoOpAuditWriter"`
### Task 11: CompositeAuditWriter
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 8, Task 9, Task 10, Task 12
**Files:**
- Create: `src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs`
- Test: `tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs`
**Step 1: Failing tests** (include a recording + a throwing fake writer):
```csharp
namespace ZB.MOM.WW.Audit.Tests;
public class CompositeAuditWriterTests
{
private sealed class RecordingWriter : IAuditWriter
{
public int Count;
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Count++; return Task.CompletedTask; }
}
private sealed class ThrowingWriter : IAuditWriter
{
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("boom");
}
private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
[Fact]
public async Task Fans_out_to_all_writers()
{
var a = new RecordingWriter(); var b = new RecordingWriter();
await new CompositeAuditWriter(new IAuditWriter[] { a, b }).WriteAsync(Evt());
Assert.Equal(1, a.Count);
Assert.Equal(1, b.Count);
}
[Fact]
public async Task One_failing_writer_does_not_stop_the_others()
{
var after = new RecordingWriter();
var sut = new CompositeAuditWriter(new IAuditWriter[] { new ThrowingWriter(), after });
await sut.WriteAsync(Evt()); // must not throw
Assert.Equal(1, after.Count);
}
}
```
**Step 2: Run — FAIL.** `dotnet test`.
**Step 3: Implement:**
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>Fans an event out to several writers. Best-effort: a failing writer does not stop the others.</summary>
public sealed class CompositeAuditWriter : IAuditWriter
{
private readonly IReadOnlyList<IAuditWriter> _inner;
/// <summary>Creates a composite over the given writers.</summary>
public CompositeAuditWriter(IEnumerable<IAuditWriter> inner)
{
ArgumentNullException.ThrowIfNull(inner);
_inner = inner.ToArray();
}
/// <inheritdoc />
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
foreach (var writer in _inner)
{
try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
catch { /* best-effort seam: a failing writer must not stop the others or the caller */ }
}
}
}
```
**Step 4: Run — PASS.** `dotnet test`.
**Step 5: Commit:** `git add -A && git commit -m "feat(audit): CompositeAuditWriter"`
### Task 12: RedactingAuditWriter
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 8, Task 9, Task 10, Task 11
**Files:**
- Create: `src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs`
- Test: `tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs`
**Step 1: Failing tests** (verify the *redacted* event reaches the inner writer):
```csharp
namespace ZB.MOM.WW.Audit.Tests;
public class RedactingAuditWriterTests
{
private sealed class CapturingWriter : IAuditWriter
{
public AuditEvent? Last;
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Last = evt; return Task.CompletedTask; }
}
private sealed class StampRedactor : IAuditRedactor
{
public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = "redacted" };
}
private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "secret" };
[Fact]
public async Task Inner_writer_receives_the_redacted_event()
{
var inner = new CapturingWriter();
var sut = new RedactingAuditWriter(new StampRedactor(), inner);
await sut.WriteAsync(Evt());
Assert.Equal("redacted", inner.Last!.DetailsJson);
}
}
```
**Step 2: Run — FAIL.** `dotnet test`.
**Step 3: Implement:**
```csharp
namespace ZB.MOM.WW.Audit;
/// <summary>Decorator: applies an <see cref="IAuditRedactor"/>, then delegates to an inner <see cref="IAuditWriter"/>.</summary>
public sealed class RedactingAuditWriter : IAuditWriter
{
private readonly IAuditRedactor _redactor;
private readonly IAuditWriter _inner;
/// <summary>Creates the decorator around <paramref name="inner"/> using <paramref name="redactor"/>.</summary>
public RedactingAuditWriter(IAuditRedactor redactor, IAuditWriter inner)
{
ArgumentNullException.ThrowIfNull(redactor);
ArgumentNullException.ThrowIfNull(inner);
_redactor = redactor;
_inner = inner;
}
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
=> _inner.WriteAsync(_redactor.Apply(evt), ct);
}
```
**Step 4: Run — PASS.** `dotnet test`.
**Step 5: Commit:** `git add -A && git commit -m "feat(audit): RedactingAuditWriter decorator"`
### Task 13: AddZbAudit DI extension
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocked by Tasks 8, 10)
**Files:**
- Create: `src/ZB.MOM.WW.Audit/AuditServiceCollectionExtensions.cs`
- Test: `tests/ZB.MOM.WW.Audit.Tests/AuditServiceCollectionExtensionsTests.cs`
**Step 1: Failing tests:**
```csharp
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.Audit.Tests;
public class AuditServiceCollectionExtensionsTests
{
[Fact]
public void Registers_null_redactor_and_noop_writer_by_default()
{
var sp = new ServiceCollection().AddZbAudit().BuildServiceProvider();
Assert.IsType<NullAuditRedactor>(sp.GetRequiredService<IAuditRedactor>());
Assert.IsType<NoOpAuditWriter>(sp.GetRequiredService<IAuditWriter>());
}
[Fact]
public void Does_not_override_a_preregistered_writer()
{
var services = new ServiceCollection();
services.AddSingleton<IAuditWriter>(new CompositeAuditWriter(System.Array.Empty<IAuditWriter>()));
var sp = services.AddZbAudit().BuildServiceProvider();
Assert.IsType<CompositeAuditWriter>(sp.GetRequiredService<IAuditWriter>());
}
}
```
**Step 2: Run — FAIL.** `dotnet test`.
**Step 3: Implement:**
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace ZB.MOM.WW.Audit;
/// <summary>DI helpers for ZB.MOM.WW.Audit.</summary>
public static class AuditServiceCollectionExtensions
{
/// <summary>
/// Registers safe defaults — <see cref="NullAuditRedactor"/> and <see cref="NoOpAuditWriter"/> —
/// using TryAdd so a consumer that has already registered a real writer/redactor wins. Consumers
/// compose <see cref="RedactingAuditWriter"/>/<see cref="CompositeAuditWriter"/> around their own sink.
/// </summary>
public static IServiceCollection AddZbAudit(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IAuditRedactor>(NullAuditRedactor.Instance);
services.TryAddSingleton<IAuditWriter>(NoOpAuditWriter.Instance);
return services;
}
}
```
**Step 4: Run — PASS.** `dotnet test`.
**Step 5: Commit:** `git add -A && git commit -m "feat(audit): AddZbAudit DI extension with safe defaults"`
### Task 14: Full test run + dotnet pack @ 0.1.0
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (blocked by Task 13)
**Files:** none (verification only)
**Step 1: Full green test run**
Run: `cd ~/Desktop/scadaproj/ZB.MOM.WW.Audit && dotnet test ZB.MOM.WW.Audit.slnx`
Expected: all tests PASS (~1820 across the type tests).
**Step 2: Pack**
Run: `./build/pack.sh`
Expected: `./artifacts/ZB.MOM.WW.Audit.0.1.0.nupkg` produced (exactly 1 nupkg). Confirm:
`ls artifacts/*.nupkg` → one file ending `0.1.0.nupkg`.
**Step 3: Commit** (artifacts are typically gitignored; commit any `.gitignore`/README touch-ups only)
```bash
git add -A && git commit -m "chore(audit): verify dotnet test green + pack @ 0.1.0" --allow-empty
```
---
## Phase C — Register the component
### Task 15: Index/registry updates + GAPS cross-check
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 14)
> These edits are in the **outer `scadaproj` repo** (not the nested library repo). Commit from `~/Desktop/scadaproj`.
**Files:**
- Modify: `components/README.md` (registry table — add `audit/` row, status Draft)
- Modify: `CLAUDE.md` (Component normalization table — add Audit row with links + a short paragraph mirroring the Auth/UI-Theme write-ups)
- Modify: `upcoming.md` (mark Audit #3 done; note logging stays with Telemetry.Serilog)
- Modify: `components/audit/GAPS.md` (cross-check against the shipped library API)
**Step 1:** Add to `components/README.md` registry:
```markdown
| Audit (event model / writer / redaction) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Audit`) | [`audit/`](audit/) |
```
**Step 2:** Add the Audit row to the `CLAUDE.md` Component-normalization table + a paragraph: built lib at `ZB.MOM.WW.Audit/` (1 package, ~1820 tests, `dotnet pack` → 1 nupkg @ 0.1.0); common ground = canonical `AuditEvent` + `Outcome` + `IAuditWriter`/`IAuditRedactor` seams; left per-project = transport/storage + domain vocab; **not yet adopted** (tracked in `components/audit/GAPS.md`); closes the loop on Auth (`Actor` = principal).
**Step 3:** In `upcoming.md`, mark the Audit recommendation (#3) as done with a pointer to `components/audit/` + `ZB.MOM.WW.Audit/`; add a one-line note that "Logging" (Tier 2) is being delivered as `ZB.MOM.WW.Telemetry.Serilog` by the health/observability pass, not as a standalone lib.
**Step 4:** Re-read `components/audit/GAPS.md` against the final shipped API; fix any drift (type/method names).
**Step 5:** Verify all new cross-links resolve (the README/CLAUDE links point at files that exist).
**Step 6: Commit**
```bash
cd ~/Desktop/scadaproj
git add components/README.md CLAUDE.md upcoming.md components/audit/GAPS.md
git commit -m "docs(audit): register component in indexes + GAPS cross-check"
```
---
## Done criteria (evidence, not assertions)
- `components/audit/` complete: README, SPEC, EVENT-MODEL, shared-contract, 3 code-verified current-state docs (each with an Adoption plan), GAPS.
- `ZB.MOM.WW.Audit/`: `dotnet test ZB.MOM.WW.Audit.slnx` green; `./build/pack.sh` → exactly **1 nupkg @ 0.1.0**.
- Indexes updated: `components/README.md`, `CLAUDE.md`, `upcoming.md` (#3 done), GAPS cross-checked.
- **No sister-repo code changed** — adoption deferred to `GAPS.md`.