docs: implementation plan for ZB.MOM.WW.Audit shared library
This commit is contained in:
@@ -0,0 +1,974 @@
|
||||
# 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 0–2 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 0–2)
|
||||
|
||||
**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 (~18–20 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, ~18–20 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`.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-01-zb-mom-ww-audit-shared-library.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Current-state — OtOpcUa", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Current-state — MxAccessGateway", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Current-state — ScadaBridge", "status": "pending"},
|
||||
{"id": 3, "subject": "Task 3: SPEC.md + EVENT-MODEL.md", "status": "pending", "blockedBy": [0, 1, 2]},
|
||||
{"id": 4, "subject": "Task 4: shared-contract/ZB.MOM.WW.Audit.md", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: components/audit/README.md + GAPS.md", "status": "pending", "blockedBy": [3, 4]},
|
||||
{"id": 6, "subject": "Task 6: Scaffold the library + test project + solution", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 7, "subject": "Task 7: AuditEvent record + AuditOutcome enum + seam interfaces", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 8: NullAuditRedactor", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: TruncatingAuditRedactor + options", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 10, "subject": "Task 10: NoOpAuditWriter", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 11, "subject": "Task 11: CompositeAuditWriter", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 12, "subject": "Task 12: RedactingAuditWriter", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 13, "subject": "Task 13: AddZbAudit DI extension", "status": "pending", "blockedBy": [8, 10]},
|
||||
{"id": 14, "subject": "Task 14: Full test run + dotnet pack @ 0.1.0", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 15: Index/registry updates + GAPS cross-check", "status": "pending", "blockedBy": [14]}
|
||||
],
|
||||
"lastUpdated": "2026-06-01"
|
||||
}
|
||||
Reference in New Issue
Block a user