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

39 KiB
Raw Blame History

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.

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

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 (KeyIdActor, EventTypeAction, CreatedUtcOccurredAtUtc, denials→Outcome.Denied, Category="ApiKey", RemoteAddressSourceNode, DetailsDetailsJson; new EventId Guid needed), Adoption plan (map IApiKeyAuditStore/ApiKeyAuditRecordIAuditWriter/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

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, StatusOutcome, rich fields → DetailsJson). Adoption plan: "align, don't replace" — rename IAuditPayloadFilterIAuditRedactor, keep its IAuditWriter. Large surface, mostly naming alignment; low priority / high blast-radius.

Step 2: Verify refs.

Step 3: Commit

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

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

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

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):

<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:

<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:

<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:

<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:

<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):

#!/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

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):

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:

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:

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:

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:

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/):

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:

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:

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:

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:

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:

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:

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:

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):

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:

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):

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:

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:

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:

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)

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:

| 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

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.