Merge feat/zb-mom-ww-audit: Audit normalization component + ZB.MOM.WW.Audit (0.1.0)

# Conflicts:
#	CLAUDE.md
#	components/README.md
This commit is contained in:
Joseph Doherty
2026-06-01 09:09:44 -04:00
37 changed files with 2131 additions and 2 deletions
+19
View File
@@ -123,6 +123,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
| Audit (event model + writer seam) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
@@ -186,6 +187,24 @@ follow-on, tracked in [`components/observability/GAPS.md`](components/observabil
Build/test from `ZB.MOM.WW.Telemetry/`: `dotnet test`. Consumer matrix: all three apps consume both
packages after adoption (OtOpcUa, MxGateway Server, ScadaBridge Host + any instrumented project).
The audit component is fully populated: a normalized [`spec`](components/audit/spec/SPEC.md), an
[`event-model`](components/audit/spec/EVENT-MODEL.md) reference, a
[`shared-contract`](components/audit/shared-contract/ZB.MOM.WW.Audit.md), three
[`current-state`](components/audit/current-state/) docs, and an adoption [`GAPS`](components/audit/GAPS.md)
backlog. Common ground = canonical `AuditEvent` record + `AuditOutcome` enum + `IAuditWriter` /
`IAuditRedactor` seams + helpers (`NullAuditRedactor`, `TruncatingAuditRedactor`, `NoOpAuditWriter`,
`CompositeAuditWriter`, `RedactingAuditWriter`) + `AddZbAudit` DI registration; left per-project =
transport/storage and domain vocabulary. Closes the loop on Auth — audit's `Actor` field = the Auth
principal. `IAuditRedactor` is aligned with Telemetry's `ILogRedactor` seam convention.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/)
(.NET 10; 1 package — `ZB.MOM.WW.Audit`; only non-BCL dependency `Microsoft.Extensions.DependencyInjection.Abstractions`;
19 tests; `dotnet pack` → 1 nupkg @ 0.1.0). Repo: `https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit`.
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/audit/GAPS.md`](components/audit/GAPS.md).
Build/test from `ZB.MOM.WW.Audit/`: `dotnet test`. Consumer matrix: all three apps consume the single
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge each map their own audit record/seam
onto the canonical type at the emit boundary).
## Per-project primary commands
Run these from inside each project directory (not from `scadaproj`).
+482
View File
@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
+10
View File
@@ -0,0 +1,10 @@
<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>
+15
View File
@@ -0,0 +1,15 @@
<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>
+59
View File
@@ -0,0 +1,59 @@
# ZB.MOM.WW.Audit
Canonical audit event model, best-effort writer seam, and redactor seam for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). This is a **library, not a service** — it is linked directly into the consuming application at build time. Transport and storage remain per-project; only the shared record + seams live here.
---
## Packages
| Package | Description | Key Dependencies |
|---|---|---|
| `ZB.MOM.WW.Audit` | Canonical `AuditEvent` record, `AuditOutcome` enum, `IAuditWriter` + `IAuditRedactor` seams, shipped helpers (`NullAuditRedactor`, `TruncatingAuditRedactor`, `NoOpAuditWriter`, `CompositeAuditWriter`, `RedactingAuditWriter`), and `AddZbAudit` DI extension. | `Microsoft.Extensions.DependencyInjection.Abstractions` |
---
## Consumer Matrix
| Consumer | ZB.MOM.WW.Audit |
|---|:---:|
| **OtOpcUa** | yes (adoption deferred) |
| **MxAccessGateway** | yes (adoption deferred) |
| **ScadaBridge** | yes (adoption deferred — "align, don't replace") |
Adoption is tracked in `components/audit/GAPS.md` in the outer `scadaproj` workspace. Each app brings its own transport (Akka broadcast / SQLite append / SQL ingest) and domain vocabulary (channels / kinds / event-types) — those stay per-project. The shared library provides the canonical record and the two seams that decouple "what to audit" from "how to store it".
---
## Auth alignment
`AuditEvent.Actor` is a string today. At adoption time it SHOULD be set to the `ZB.MOM.WW.Auth` principal identifier — this is the "audit closes the loop on Auth" hinge described in the spec. No compile-time dependency on `ZB.MOM.WW.Auth` is introduced here; the alignment is by convention.
---
## Versioning
The single package is versioned from `Directory.Build.props`. The current release is **0.1.0**. A single version bump in `Directory.Build.props` bumps the package.
---
## Building and packing
```bash
# From ZB.MOM.WW.Audit/
dotnet build ZB.MOM.WW.Audit.slnx
dotnet test ZB.MOM.WW.Audit.slnx
# Produce the NuGet package into ./artifacts/
./build/pack.sh
```
---
## Design documentation
Full design docs live in the `components/audit` folder of the SCADA project workspace:
- `~/Desktop/scadaproj-audit/components/audit/spec/SPEC.md` — overall audit specification
- `~/Desktop/scadaproj-audit/components/audit/spec/EVENT-MODEL.md` — field-by-field event model + per-project mapping table
- `~/Desktop/scadaproj-audit/components/audit/shared-contract/ZB.MOM.WW.Audit.md` — public API contract (on paper)
- `~/Desktop/scadaproj-audit/components/audit/GAPS.md` — adoption backlog
+8
View File
@@ -0,0 +1,8 @@
<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>
+4
View File
@@ -0,0 +1,4 @@
#!/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
@@ -0,0 +1,50 @@
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>
/// <remarks>Participates in record value-equality as a normalized instant: two events whose
/// <c>OccurredAtUtc</c> denote the same instant at different offsets (e.g. <c>12:00+05:00</c> and
/// <c>07:00Z</c>) compare equal and share a hash code. Relevant to consumers that dedup/key on
/// <see cref="AuditEvent"/> value-equality.</remarks>
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; }
}
@@ -0,0 +1,12 @@
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,
}
@@ -0,0 +1,21 @@
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;
}
}
@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Fans an event out to several writers. Best-effort: a failing writer does not stop the others.</summary>
/// <remarks>A failing writer's exception is swallowed so the fan-out drains and the caller is never
/// aborted — but <see cref="OperationCanceledException"/> is re-thrown so cancellation is honored.</remarks>
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)
{
ArgumentNullException.ThrowIfNull(evt);
foreach (var writer in _inner)
{
try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { throw; } // honor cancellation; do not swallow
catch { /* best-effort seam: a failing writer must not stop the others or the caller */ }
}
}
}
@@ -0,0 +1,13 @@
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);
}
@@ -0,0 +1,12 @@
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);
}
@@ -0,0 +1,12 @@
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;
}
@@ -0,0 +1,12 @@
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;
}
@@ -0,0 +1,24 @@
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)
{
ArgumentNullException.ThrowIfNull(evt);
return _inner.WriteAsync(_redactor.Apply(evt), ct);
}
}
@@ -0,0 +1,41 @@
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);
}
}
@@ -0,0 +1,12 @@
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]";
}
@@ -0,0 +1,18 @@
<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>
@@ -0,0 +1,67 @@
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());
}
[Fact]
public void Same_instant_at_different_offset_compares_equal()
{
// Guards the UTC-normalizing init-setter: if OccurredAtUtc is ever "simplified" back to a
// plain auto-property, these two (same instant, different offset) would stop comparing equal.
var id = Guid.NewGuid();
var utc = new DateTimeOffset(2026, 6, 1, 7, 0, 0, TimeSpan.Zero);
var plus5 = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5)); // same instant as utc
AuditEvent With(DateTimeOffset when) =>
new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
Assert.Equal(With(utc), With(plus5));
Assert.Equal(With(utc).GetHashCode(), With(plus5).GetHashCode());
}
}
@@ -0,0 +1,32 @@
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>());
}
[Fact]
public void Does_not_override_a_preregistered_redactor()
{
var services = new ServiceCollection();
services.AddSingleton<IAuditRedactor>(new TruncatingAuditRedactor());
var sp = services.AddZbAudit().BuildServiceProvider();
Assert.IsType<TruncatingAuditRedactor>(sp.GetRequiredService<IAuditRedactor>());
}
}
@@ -0,0 +1,48 @@
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 sealed class CancellingWriter : IAuditWriter
{
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new OperationCanceledException();
}
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);
}
[Fact]
public async Task Cancellation_is_propagated_not_swallowed()
{
// OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed).
var after = new RecordingWriter();
var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after });
await Assert.ThrowsAsync<OperationCanceledException>(() => sut.WriteAsync(Evt()));
}
}
@@ -0,0 +1,12 @@
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);
}
}
@@ -0,0 +1,12 @@
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));
}
}
@@ -0,0 +1,26 @@
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);
}
}
@@ -0,0 +1,56 @@
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);
}
[Fact]
public void Marker_longer_than_max_clips_the_marker_itself()
{
// Misconfiguration: marker longer than the cap. Must not throw; clips to the first max chars.
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 3, TruncationMarker = "…[truncated]" };
var r = new TruncatingAuditRedactor(opts);
var result = r.Apply(Evt(new string('x', 20)));
Assert.Equal(3, result.DetailsJson!.Length);
}
}
@@ -0,0 +1,18 @@
<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>
+1
View File
@@ -21,6 +21,7 @@ specs and analyses that *drive* changes made in the individual repos.
| UI Theme (layout / tokens / components) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Theme`) | [`ui-theme/`](ui-theme/) |
| Health (readiness / liveness / active-node) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Health` lib (3 packages) | [`health/`](health/) |
| Observability (metrics / traces / logs) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Shared `ZB.MOM.WW.Telemetry` lib (2 packages) | [`observability/`](observability/) |
| Audit (event model + writer seam) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Audit`) | [`audit/`](audit/) |
> Add a row when you start normalizing a new component. Status: `Draft` → `Reviewed` → `Adopting` → `Converged`.
+114
View File
@@ -0,0 +1,114 @@
# Audit — gaps & adoption backlog
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach the shared `ZB.MOM.WW.Audit` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
> **Adoption is deferred this round.** The library is being designed (shared contract in
> [`shared-contract/ZB.MOM.WW.Audit.md`](shared-contract/ZB.MOM.WW.Audit.md)) but is not yet
> wired into any app — exactly where `ZB.MOM.WW.Auth` and `ZB.MOM.WW.Theme` sit today.
> The items below are the follow-on work; each lands as a separate PR per project.
## Divergence vs spec
### §1 Canonical record (`AuditEvent`)
| Canonical field | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| `EventId` (Guid, required) | ✅ — idempotency key; buffer key + filtered-unique DB index | ⛔ — no event key; only an `AUTOINCREMENT` rowid (`AuditId`) | ✅ — direct |
| `OccurredAtUtc` (DateTimeOffset, required) | 🟡 — `DateTime` UTC; widen at mapping boundary | 🟡 — `DateTimeOffset` but store-assigned (not caller-supplied); direct after widening | 🟡 — `DateTime` UTC-forced; widen at mapping boundary |
| `Actor` (string, required) | ✅ — direct (`AuditEvent.Actor``ConfigAuditLog.Principal`) | 🟡 — `KeyId` nullable; keyless events (`init-db`/`list-keys`) need a `"system"`/`"cli"` fallback | 🟡 — nullable on system-originated rows; fallback needed |
| `Action` (string, required) | 🟡 — `Action` field exists, but persisted as `"{Category}:{Action}"` composite in `EventType`; canonical keeps them separate | ✅ — `EventType` literal direct | 🟡 — derived as `{Channel}.{Kind}` (e.g. `ApiOutbound.ApiCall`) |
| `Outcome` (AuditOutcome, required) | ⛔ **NEW** — derived from `EventType` vocabulary; not stored today | ⛔ **NEW** — derived: `constraint-denied``Denied`, else `Success` | ⛔ **NEW** — derived from `Status` (+`InboundAuthFailure` Kind→`Denied`) |
| `Category` (string?) | ✅ — `AuditEvent.Category` (e.g. `"Config"`) | ⛔ — no field; constant `"ApiKey"` at mapping | ✅ — `Channel` |
| `Target` (string?) | ⛔ — no dedicated field; closest is `DetailsJson` | ⛔ — embedded in `Details` text (`commandKind`/`target`) | ✅ — direct |
| `SourceNode` (string?) | ✅ — `SourceNode` (logical cluster node / host name, NOT an OPC UA NodeId) | 🟡 — `RemoteAddress`; dashboard path only (null on CLI/constraint paths) | ✅ — direct |
| `CorrelationId` (Guid?) | ✅ — direct (`CorrelationId.Value`) | ⛔ — not captured today; left null | ✅ — direct |
| `DetailsJson` (string?) | ✅ — direct (JSON CHECK constraint enforced) | 🟡 — `Details` is a plain string, not JSON; wrap or store as-is | 🟡 — ~15 rich/plumbing fields serialize here at the cross-project reporting boundary |
### §2 `IAuditWriter` seam
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Named seam | ⛔ — no `IAuditWriter`; `AuditWriterActor` is the sink, consumed directly via Akka messaging | ⛔ — `IApiKeyAuditStore` (narrow, two-method) is the seam; no general `IAuditWriter` | ✅ — `IAuditWriter` with `WriteAsync(AuditEvent, CancellationToken)` signature; "failures must NEVER abort the user-facing action" contract; best-effort |
| Best-effort / never throws | 🟡 — the actor drops a failed flush (best-effort), but the seam is not a typed interface a caller can inject independently | ⛔ — no contract; `AppendAsync` may propagate | ✅ |
| Record type at the seam | 🟡 — OtOpcUa's own `AuditEvent` (8 fields, with Commons value-types `NodeId`/`CorrelationId`) | ⛔ — `ApiKeyAuditEntry` (4 fields) | 🟡 — ScadaBridge's ~25-field `AuditEvent` (rich record; adoption = keep own record, adopt canonical interface name + `AuditOutcome`) |
### §3 `IAuditRedactor` seam
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Named seam | ⛔ — no redactor; no payload filtering today | ⛔ — no redactor; safety by construction (entry type cannot carry a secret) | ✅ — `IAuditPayloadFilter` (`AuditEvent Apply(AuditEvent)`, pure/never-throws/over-redacts); **only the name differs** from canonical `IAuditRedactor` |
| Over-redacts on failure | ⛔ — n/a | ⛔ — n/a | ✅ — `SafeDefaultAuditPayloadFilter` is the reference |
### §4 `AuditOutcome` — the new normalized field
`Outcome` is a **genuinely new field** across all three projects. No app stores it today;
each encodes it implicitly. All three must derive and emit it at adoption:
**Gap O1 (OtOpcUa):** derive from `EventType` vocabulary — `OpcUaAccessDenied` /
`CrossClusterNamespaceAttempt``Denied`; config-write verbs → `Success`. No `Failure`
value exists in OtOpcUa's vocabulary today (failed flushes are dropped, not emitted), so
OtOpcUa will produce only `Success` / `Denied` until/unless failure events are added.
**Gap O2 (MxGateway):** derive — `constraint-denied``Denied`; all others → `Success`.
No `Failure` events are emitted today.
**Gap O3 (ScadaBridge):** derive from `AuditStatus``Delivered``Success`;
`Failed` / `Parked` / `Discarded``Failure`; `Kind = InboundAuthFailure``Denied`.
In-flight states (`Submitted` / `Forwarded` / `Attempted`) collapse to the last-known
terminal state when projecting; `Skipped` is excluded from the canonical projection.
### §5 `Actor` → Auth principal
At adoption, every emit site should supply the `ZB.MOM.WW.Auth` principal as `Actor`
(string). The library carries no Auth dependency — `Actor` is a plain `string` — but the
handshake with Auth is the semantic goal (closes the loop).
**Gap P1 (all 3):** at adoption, update emit sites to populate `Actor` from the Auth
principal (LDAP user / API-key name). Auth adoption (#8 in `components/auth/GAPS.md`) is a
prerequisite for the full story; until then, use the existing actor string.
### §6 OtOpcUa two-producer problem
OtOpcUa has **two writers to `ConfigAuditLog`**: the structured Akka `AuditEvent` path AND
older SQL stored procedures that `INSERT` directly (bare `EventType`, NULL `EventId` /
`CorrelationId`, populated `ClusterId` / `GenerationId`). Normalization targets the
structured path only; the SP path stays per-project.
**Gap Q1 (OtOpcUa):** decide at adoption whether to route SP events through the actor
or leave them non-idempotent. Also: the `ClusterId`-filter / actor-never-sets-`ClusterId`
mismatch (Admin UI `ClusterAudit.razor` filters by `ClusterId`, but the actor path sets
`NodeId` not `ClusterId`, so structured rows are invisible to the cluster view). Fix when
normalizing the query surface.
## Adoption backlog (ordered)
| # | Item | Projects | Priority | Effort | Risk | Notes |
|---|---|---|---|---|---|---|
| 1 | **OtOpcUa:** rename `AuditWriterActor` → implements `IAuditWriter`; replace `Commons/Messages/Audit/AuditEvent.cs` with canonical record; add `Outcome` derivation at every emit site (Gap O1) | OtOpcUa | Med | M | Med | Actor internals (batching / dedup / flush triggers) stay bespoke; only the seam type and record change. Commons value-types `NodeId`/`CorrelationId` bridged at construction. |
| 2 | **MxGateway:** map `IApiKeyAuditStore` / `ApiKeyAuditEntry` / `ApiKeyAuditRecord``IAuditWriter` / `AuditEvent`; generate `EventId` per write; add `"system"`/`"cli"` Actor fallback; constant `Category = "ApiKey"`; `constraint-denied``Outcome.Denied` (Gaps O2, record gaps) | MxGateway | Low | S | Med | ⚠ **COORDINATE** — a parallel session is editing this repo for the MEL→Serilog migration (Health/Telemetry normalization). Do NOT start until the Serilog session has landed (or is explicitly fenced off); the two efforts share `Security/Authentication/` DI wiring. |
| 3 | **ScadaBridge:** rename `IAuditPayloadFilter``IAuditRedactor` (or alias during transition); adopt canonical `AuditOutcome` enum (Gap O3); confirm writer contract matches (already byte-for-byte) | ScadaBridge | Low | S | High | **"Align, don't replace."** Blast radius is HIGH — `IAuditPayloadFilter` is used across the entire pipeline (site, central, wiring). Rename + alias only; no transport/storage/record change. `DefaultAuditPayloadFilter` / `SafeDefaultAuditPayloadFilter` implementations unchanged. |
| 4 | **All:** populate `Actor` from `ZB.MOM.WW.Auth` principal at emit sites (Gap P1) | All 3 | Low | S | Low | **Prerequisite:** Auth adoption per `components/auth/GAPS.md` #8. Until Auth is adopted, leave the existing actor string as-is. |
| 5 | **OtOpcUa:** reconcile two-producer problem — decide SP path routing + fix `ClusterId`-filter / actor mismatch in `ClusterAudit.razor` (Gap Q1) | OtOpcUa | Low | S | Low | Normalization does not unify the SP path; this is a reconcile item to decide and document. The mismatch means structured `AuditEvent` rows are currently invisible to the cluster-scoped view. |
| 6 | **MxGateway:** add `CorrelationId` capture at constraint denial + dashboard paths; structured `Target` from `Details` text (currently embedded as a plain string in `ConstraintEnforcer`) | MxGateway | Low | S | Low | Nice-to-have parity; not required for adoption. `CorrelationId` and `Target` canonical fields left null until this is done. |
**Sequencing:** #3 (ScadaBridge rename) is lowest-risk and self-contained — do it first (or
last, depending on blast-radius appetite). #1 (OtOpcUa) is medium effort but independent; it
can start once the shared library is built. #2 (MxGateway) is the smallest code change but
has the highest **coordination dependency** — gate it on the Serilog migration landing first.
#4 (Actor→Auth) is blocked on Auth adoption and is the last to close. #5 and #6 are cleanup
items with no bearing on shared-library adoption.
Each adoption lands as an opt-in version bump per project behind the seam; the shared library
is consumed but the bespoke transport/storage/UI for each project is not touched.
## Decisions still open
- ScadaBridge `IAuditPayloadFilter``IAuditRedactor`: outright rename vs. transitional alias
(both are valid; alias reduces blast radius in the short term).
- MxGateway `Details` plain string → `DetailsJson`: store as-is or wrap in a JSON object at
the mapping boundary.
- `AuditOutcome` column in OtOpcUa storage: add a new `Outcome` column to `ConfigAuditLog`
or fold into `DetailsJson` / derive at read time (schema change vs. runtime cost).
- OtOpcUa SP path: route through the actor path (unified producer) or leave as a bespoke
secondary writer with its own column conventions (separate reconcile effort).
+72
View File
@@ -0,0 +1,72 @@
# Audit (who-did-what)
Status: **Draft**. Normalized component — path to shared code. Goal: converge the three
sister projects onto a canonical `AuditEvent` record + `AuditOutcome` enum + two thin seams
(`IAuditWriter`, `IAuditRedactor`), proposed as the `ZB.MOM.WW.Audit` library, while each
project keeps its own transport, storage, domain vocabulary, and redaction policy.
- The one target: [`spec/SPEC.md`](spec/SPEC.md)
- Canonical event model + field reference: [`spec/EVENT-MODEL.md`](spec/EVENT-MODEL.md)
- The proposed shared library: [`shared-contract/ZB.MOM.WW.Audit.md`](shared-contract/ZB.MOM.WW.Audit.md)
- Divergences + backlog: [`GAPS.md`](GAPS.md)
- Current state, per project: [`current-state/`](current-state/)
## Why audit is a strong normalization candidate
All three projects record a structured who-did-what trail with an actor identity, an action
verb, and a timestamp. Two (OtOpcUa + ScadaBridge) already have a named `AuditEvent` record
with an `EventId` idempotency key, `Actor`, and `CorrelationId`. ScadaBridge already ships
**both** canonical seams under slightly different names (`IAuditWriter` is byte-for-byte the
spec; `IAuditPayloadFilter` is the canonical `IAuditRedactor`). OtOpcUa's record is almost
field-for-field aligned. MxGateway has a narrow API-key-lifecycle log that maps cleanly.
The one new field across all three is `AuditOutcome` — no project stores it explicitly today;
each encodes it implicitly and derives it at adoption. This is the bulk of the per-project
work. Transport, storage, domain vocabulary, and redaction policy are **not** unified — each
project keeps its own bespoke implementation behind the seam.
**Audit closes the loop on Auth.** Every audit row's `Actor` is exactly the identity that the
`ZB.MOM.WW.Auth` component normalizes (LDAP/GLAuth principal, API-key name). The library keeps
`Actor` as a plain `string` (no Auth dependency), but at adoption each emit site supplies the
Auth principal.
**`IAuditRedactor` naming is aligned with Telemetry's `ILogRedactor`** — same shape and naming
discipline so a future `ZB.MOM.WW.Hosting` aggregator wires both redactors with one mental
model — but there is no cross-package dependency between the two libraries.
## Status by project
| Project | Audit today | Seams present | `AuditOutcome` | Adoption status |
|---|---|---|---|---|
| **OtOpcUa** | Akka cluster-broadcast `AuditEvent` → cluster-singleton `AuditWriterActor` (batch 500/5 s, two-layer dedup) over EF `ConfigAuditLog` (SQL Server). Also a legacy SQL stored-procedure write path (bare `EventType`, NULL `EventId`). Admin UI page `ClusterAudit.razor`. | No named `IAuditWriter` seam; no redactor seam. | Not stored — encoded in `EventType` strings (`OpcUaAccessDenied`/`CrossClusterNamespaceAttempt``Denied`; config-write verbs → `Success`). | Not started |
| **MxAccessGateway** | Single SQLite-backed `IApiKeyAuditStore` / `ApiKeyAuditEntry` — key lifecycle (CLI + dashboard) + constraint denials only. No authn events persisted; no production read consumer. | Narrow custom seam (`IApiKeyAuditStore`); no general `IAuditWriter`; redaction is by-construction (secret never enters the record type). | Not stored — derived: `constraint-denied``Denied`; all others → `Success`. | Not started |
| **ScadaBridge** | Full pipeline: site SQLite hot-path (`SqliteAuditWriter` + ring-buffer fallback) → Akka `ClusterClient` forwarder → central MS SQL (ingest / reconcile / purge / partition maintenance). Rich ~25-field `AuditEvent` record. CLI `export`/`verify-chain`; Blazor audit UI. | ✅ `IAuditWriter` (matches canonical contract word-for-word); ✅ `IAuditPayloadFilter` (= canonical `IAuditRedactor`, identical signature, pure/never-throws/over-redacts). | Not stored explicitly — derived from `Status` (`Delivered``Success`; `Failed`/`Parked`/`Discarded``Failure`; `Kind = InboundAuthFailure``Denied`). | Not started (align, don't replace) |
See each project's `current-state/<project>/CURRENT-STATE.md` for code-verified detail and
adoption plan:
- [`current-state/otopcua/CURRENT-STATE.md`](current-state/otopcua/CURRENT-STATE.md)
- [`current-state/mxaccessgw/CURRENT-STATE.md`](current-state/mxaccessgw/CURRENT-STATE.md)
- [`current-state/scadabridge/CURRENT-STATE.md`](current-state/scadabridge/CURRENT-STATE.md)
## Normalized vs. left per-project
**Normalized (the shared `ZB.MOM.WW.Audit` library):** the canonical `AuditEvent` record
(5 required fields + 4 optional common + `DetailsJson` extension bag); the `AuditOutcome`
enum (`Success | Failure | Denied`); the `IAuditWriter` seam (best-effort, never throws to
caller); the `IAuditRedactor` seam (pure, never throws, over-redacts on failure); shipped
helpers (`NoOpAuditWriter`, `CompositeAuditWriter`, `RedactingAuditWriter`,
`NullAuditRedactor`, `TruncatingAuditRedactor`). Library has no Akka / EF / SQLite / Serilog
dependency; its only non-BCL dependency is `Microsoft.Extensions.DependencyInjection.Abstractions`.
**Left per-project (each project keeps these behind the seam):** transport and storage (Akka
singleton + EF/SQL Server; SQLite; site-SQLite + central MS SQL + forwarding/reconcile
pipeline); domain vocabulary (`EventType` strings / API-key event-type literals / `Channel` +
`Kind` + `Status` enums); query, CLI, and UI surfaces (`ClusterAudit.razor`; `ListRecentAsync`;
`export` / `verify-chain`; Blazor audit pages); redaction *policy* (which fields/payloads are
sensitive — only the `IAuditRedactor` *seam* is shared).
> **Adoption is deferred this round.** The `ZB.MOM.WW.Audit` library is being designed and
> the shared contract defined, but none of the three apps wire it in yet — exactly where
> `ZB.MOM.WW.Auth` and `ZB.MOM.WW.Theme` sit today. The per-project adoption backlog is in
> [`GAPS.md`](GAPS.md).
@@ -0,0 +1,118 @@
# Audit — current state: MxAccessGateway (`mxaccessgw`)
Repo: `~/Desktop/MxAccessGateway` (Gitea `mxaccessgw`). Stack: .NET 10 gateway (x64) + x86/net48 worker.
Audit lives entirely in the **gateway** (.NET 10); the worker records nothing.
All paths relative to repo root; audit code under `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`. Verified 2026-06-01.
This is the **narrowest** of the three implementations: a single SQLite-backed append-only log scoped
to **API-key lifecycle and constraint denials**. There is no general-purpose audit abstraction, no
separate redaction seam, and no CorrelationId. Read-back exists but has no production consumer today.
## How it works today
The audit log is one seam, `IApiKeyAuditStore`
(`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs:6`), with exactly two
operations: `AppendAsync(ApiKeyAuditEntry, ...)` (`IApiKeyAuditStore.cs:14`) and
`ListRecentAsync(int count, ...)` (`IApiKeyAuditStore.cs:22`). Single implementation,
`SqliteApiKeyAuditStore` (`SqliteApiKeyAuditStore.cs:5`), registered as a singleton in
`AuthStoreServiceCollectionExtensions.cs:23` alongside the rest of the auth stores.
- **Append-side shape:** callers pass `ApiKeyAuditEntry(string? KeyId, string EventType, string? RemoteAddress, string? Details)`
(`ApiKeyAuditEntry.cs:3`). The store sets the timestamp itself — `AppendAsync` writes
`created_utc = DateTimeOffset.UtcNow.ToString("O")` (`SqliteApiKeyAuditStore.cs:20`), so the caller
cannot supply the time and there is **no idempotency/event key** (the only identity is the DB
`AUTOINCREMENT` rowid).
- **Read-side shape:** `ListRecentAsync` returns `ApiKeyAuditRecord(long AuditId, string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details)`
(`ApiKeyAuditRecord.cs:3`), ordered `audit_id DESC LIMIT $count` (`SqliteApiKeyAuditStore.cs:38-42`),
returning `[]` for `count <= 0` (`SqliteApiKeyAuditStore.cs:29-32`).
- **Storage:** SQLite, the same gateway-owned auth DB (`AuthSqliteConnectionFactory`, WAL; default
`C:\ProgramData\MxGateway\gateway-auth.db`). Table `api_key_audit` is created by
`SqliteAuthStoreMigrator.cs:95-102``audit_id INTEGER PRIMARY KEY AUTOINCREMENT, key_id TEXT NULL,
event_type TEXT NOT NULL, remote_address TEXT NULL, created_utc TEXT NOT NULL, details TEXT NULL`,
plus index `ix_api_key_audit_key_id_created_utc` (`SqliteAuthStoreMigrator.cs:107-108`). Table name
constant `SqliteAuthSchema.ApiKeyAuditTable = "api_key_audit"` (`SqliteAuthSchema.cs:11`). The log is
append-only: there is no update/delete/prune path.
- **Producers (three, all in the gateway):**
- **Admin CLI** `ApiKeyAdminCliRunner` — its private `AppendAuditAsync` (`ApiKeyAdminCliRunner.cs:153`)
always passes `RemoteAddress: null` (`ApiKeyAdminCliRunner.cs:163`). Event types:
`"init-db"` (`:48`), `"create-key"` (`:74`), `"list-keys"` (`:83`),
`"revoke-key"` with details `revoked`/`not-found-or-already-revoked` (`:102`),
`"rotate-key"` with details `rotated`/`not-found` (`:121`).
- **Dashboard** `DashboardApiKeyManagementService` — its `AppendAuditAsync` (`:197`) captures
`RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString()` (`:207`).
Event types: `"dashboard-create-key"` (`:62`), `"dashboard-revoke-key"` (`:103`, details
`revoked`/`not-found-or-already-revoked`), `"dashboard-rotate-key"` (`:145`, details `rotated`/`not-found`),
`"dashboard-delete-key"` (`:187`, details `deleted`/`not-found-or-active`).
- **Constraint denials** `ConstraintEnforcer.RecordDenialAsync` (`ConstraintEnforcer.cs:117`) writes
`EventType: "constraint-denied"`, `RemoteAddress: null`, and `Details:
$"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"` (`ConstraintEnforcer.cs:124-129`).
This is the only "denial" event in the log.
- **No authn events.** The verifier (`ApiKeyVerifier`) and the gRPC authorization interceptor
(`GatewayGrpcAuthorizationInterceptor`) do **not** write to the audit store — authentication
success/failure and `Unauthenticated`/`PermissionDenied` outcomes are surfaced as gRPC statuses and
(per policy) discriminated for logging, but are not persisted as audit rows. So in practice the log
records **key lifecycle (CLI + dashboard) + constraint denials**, not per-request authn outcomes.
- **No separate redaction seam — scrubbing is structural, in the store/entry shape.** There is no
redactor, scrubber, sanitizer, or masking helper. Safety comes from *what the entry type can carry*:
`ApiKeyAuditEntry` has no field for a secret, and every caller passes only a `KeyId` (the public
key identifier, never the secret), an event-type literal, and short hand-built `Details` strings —
the secret/pepper never enters the audit path. This aligns with the repo policy that "API keys,
passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs"
(`CLAUDE.md:79`). Net: redaction is by construction, not a pluggable seam.
- **Read-back has no production consumer.** `ListRecentAsync` is called only by tests
(`SqliteAuthStoreTests`, `ApiKeyAdminCliRunnerTests`). The dashboard `ApiKeysPage.razor` mentions the
audit log only in a delete-confirmation string (`ApiKeysPage.razor:321`) — it does **not** render it.
There is no UI or RPC that surfaces audit history today.
## Mapping to the canonical record
Target: `ZB.MOM.WW.Audit`'s `AuditEvent { Guid EventId; DateTimeOffset OccurredAtUtc; string Actor;
string Action; AuditOutcome Outcome; string? Category; string? Target; string? SourceNode;
Guid? CorrelationId; string? DetailsJson; }` with `AuditOutcome ∈ { Success, Failure, Denied }`.
| `AuditEvent` field | Source today | Mapping note |
|---|---|---|
| `EventId` (Guid, required) | — none — | **Must be generated** at write time. `ApiKeyAuditRecord` has only the autoincrement `AuditId` (`ApiKeyAuditRecord.cs:4`); no idempotency key exists. |
| `OccurredAtUtc` (required) | `CreatedUtc` (`ApiKeyAuditRecord.cs:8`), set as `DateTimeOffset.UtcNow` in the store (`SqliteApiKeyAuditStore.cs:20`) | Direct. Note: time is store-assigned today, not caller-supplied. |
| `Actor` (required) | `KeyId` (`ApiKeyAuditRecord.cs:5`) | Nullable today (`init-db`/`list-keys` pass `null`); the canonical `Actor` is required, so a fallback (e.g. `"system"`/`"cli"`) is needed for keyless events. |
| `Action` (required) | `EventType` (`ApiKeyAuditRecord.cs:6`) | Direct. CLI vocab: `init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key`; dashboard vocab: `dashboard-create-key`, `dashboard-revoke-key`, `dashboard-rotate-key`, `dashboard-delete-key`; plus `constraint-denied`. |
| `Outcome` (required) | derived | `constraint-denied``Denied`; everything else → `Success` (no `Failure` events are emitted today). |
| `Category` | — none — | Constant `"ApiKey"`. |
| `Target` | — none as a field — | No structured target. (`ConstraintEnforcer` does embed `commandKind`/`target` inside `Details` text, but there is no dedicated column.) |
| `SourceNode` | `RemoteAddress` (`ApiKeyAuditRecord.cs:7`) | Direct; populated only on the dashboard path (`DashboardApiKeyManagementService.cs:207`), `null` on CLI/constraint paths. |
| `CorrelationId` | — none — | Not captured today. |
| `DetailsJson` | `Details` (`ApiKeyAuditRecord.cs:9`) | Today this is a **plain string**, not JSON; either store as-is in `DetailsJson` or wrap as a small JSON object. |
---
## Adoption plan → `ZB.MOM.WW.Audit`
**Effort: LOW.** The seam is tiny (one interface, two methods, one record pair) and the data already
maps cleanly onto `AuditEvent`. Concretely:
1. **Adapter, not rewrite.** Map `IApiKeyAuditStore` → the shared `IAuditWriter`, and
`ApiKeyAuditEntry`/`ApiKeyAuditRecord``AuditEvent`, using the table above: generate a new
`EventId` Guid per write; `KeyId → Actor` (with a `"system"` fallback for null); `EventType → Action`;
`CreatedUtc → OccurredAtUtc`; `RemoteAddress → SourceNode`; `constraint-denied → Outcome.Denied`,
else `Success`; constant `Category = "ApiKey"`; `Details → DetailsJson`. The three producers
(`ApiKeyAdminCliRunner`, `DashboardApiKeyManagementService`, `ConstraintEnforcer`) keep their call
sites — only the injected type changes.
2. **Redaction stays by-construction.** No separate redactor needs porting; just preserve the rule that
callers never put secrets in `DetailsJson` (mirrors `CLAUDE.md:79`). The shared writer can keep its
own redaction policy as a defence-in-depth layer.
3. **Read-back is free to drop or defer.** `ListRecentAsync` has no production consumer, so the adapter
need not implement a shared query API on day one — only the test/CLI read paths exercise it.
4. **No new dimensions required.** `CorrelationId` and a structured `Target` are absent today and are
*not* in scope to add as part of adoption (descriptive parity only); the canonical record simply
leaves them `null`.
**Coordination risk — sequence against the health/observability work.** A parallel session is actively
editing **this same repo** (`mxaccessgw`) for the MEL → Serilog logging migration
(`ZB.MOM.WW.Health` + `ZB.MOM.WW.Telemetry` normalization). Because audit adoption here also touches the
gateway's `Security/Authentication/` wiring (DI registration in `AuthStoreServiceCollectionExtensions.cs`,
and the three producer call sites), the two efforts can collide on the same files and on logging-pipeline
DI. **Do not start MxGateway audit adoption until the Serilog migration in this repo has landed (or is
explicitly fenced off)**, and confirm with the orchestrator that the logging session is not mid-flight in
`Security/` before opening a PR. The audit and logging seams are conceptually independent (audit = durable
SQLite record of who-did-what; logging = operational telemetry), but they share the gateway's startup/DI
surface, so they must be merged in a defined order rather than in parallel.
@@ -0,0 +1,140 @@
# Audit — current state: OtOpcUa
Repo: `~/Desktop/OtOpcUa` (Gitea `lmxopcua`). Stack: .NET 10, Akka.NET cluster, EF Core + SQL Server.
All paths below are relative to the repo root. Verified against source on 2026-06-01.
OtOpcUa already has a structured, idempotent audit pipeline: a cluster-broadcast `AuditEvent`
message, a cluster-singleton writer actor that batches and bulk-inserts, and an append-only
`ConfigAuditLog` EF entity with two-layer dedup. There is **also** a second, older write path —
SQL stored procedures that `INSERT dbo.ConfigAuditLog` directly — so the table has two
producers with slightly different column conventions (see §1).
## 1. How it works today
**Record shape**`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs:9-17`:
a sealed record `AuditEvent(Guid EventId, string Category, string Action, string Actor,
DateTime OccurredAtUtc, string? DetailsJson, NodeId SourceNode, CorrelationId CorrelationId)`.
`NodeId` and `CorrelationId` are Commons value-types — `NodeId` wraps a string (the *logical
cluster node / host name*, explicitly **not** an OPC UA NodeId per its XML doc,
`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/NodeId.cs:3-8`); `CorrelationId` wraps a `Guid`
(`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/CorrelationId.cs:3`).
**Transport**`AuditEvent` is an Akka message meant to be sent to the `AuditWriterActor`
**cluster singleton** (`AuditEvent.cs:6` describes it as "cluster-broadcast … consumed by the
`AuditWriterActor` singleton"). The singleton is registered through Akka.Hosting at
`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs:68-75`
(`WithSingleton<AuditWriterActorKey>(AuditWriterSingletonName, …)`). Any cluster member can
emit an `AuditEvent`; the singleton is the one sink that persists it.
**Storage** — EF entity `ConfigAuditLog`
(`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs:7-44`): append-only
("Grants revoked for UPDATE/DELETE on all principals", `ConfigAuditLog.cs:4-5`). Columns:
`AuditId` (identity PK), `Timestamp` (default `SYSUTCDATETIME()`), `Principal`, `EventType`,
`ClusterId?`, `NodeId?`, `GenerationId?`, `DetailsJson?`, `EventId?` (Guid), `CorrelationId?`
(Guid). Mapping/constraints in `OtOpcUaConfigDbContext.cs:429-463`: `DetailsJson` must be valid
JSON (`CK_ConfigAuditLog_DetailsJson_IsJson`, line 435-436); `Principal`/`EventType`/`ClusterId`/`NodeId`
length-capped (lines 441-444); supporting indexes `IX_ConfigAuditLog_Cluster_Time` (line 449-451)
and `IX_ConfigAuditLog_Generation` (line 452-454).
**Writer / batching**`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`:
a `ReceiveActor` with `FlushBatchSize = 500` (line 25) and `FlushInterval = 5s` (line 26).
It buffers events in a `Dictionary<Guid, AuditEvent>` keyed by `EventId` (line 30), flushing
when the buffer hits 500 (line 60), when the 5s periodic timer fires (`PreStart`, line 50-53),
or on `PreRestart`/`PostStop` (lines 96-107) so a supervisor swap or coordinated shutdown does
not lose the buffer. `FlushBuffer` (lines 63-93) snapshots and clears the buffer, then for each
event constructs a `ConfigAuditLog` row (lines 75-84): `Timestamp = OccurredAtUtc`,
`Principal = Actor`, `EventType = $"{Category}:{Action}"`, `NodeId = SourceNode.Value`,
`DetailsJson`, `EventId`, `CorrelationId = CorrelationId.Value`. A failed flush is logged and the
batch is **dropped** (`catch` at lines 89-92) — best-effort, no retry/dead-letter.
**Dedup / idempotency (two layers)** — described at `AuditWriterActor.cs:17-21`:
1. *In-buffer* — duplicate `EventId`s within a batch collapse via the dictionary (last-write-wins;
`HandleEvent`, lines 55-61).
2. *Database* — a **filtered unique index** `UX_ConfigAuditLog_EventId` (`OtOpcUaConfigDbContext.cs:459-462`,
`IsUnique()` + `HasFilter("[EventId] IS NOT NULL")`) gives cross-restart safety: a retry of an
already-flushed batch hits the constraint, the duplicate insert is dropped, and the rest of the
batch survives. `EventId`/`CorrelationId` are nullable so legacy/backfill rows (NULL) don't
collide — confirmed in the entity XML (`ConfigAuditLog.cs:33-43`) and migration
`Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs:26-31`.
**Scope** — two producers, two conventions:
- **Akka `AuditEvent` path** (the structured one): config writes + authorization checks. The
EventType vocabulary lives in the entity XML doc (`ConfigAuditLog.cs:18`): `DraftCreated |
DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled |
ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt |
OpcUaAccessDenied | …`. Note the access-denied / cross-cluster entries are authz-check events,
not config writes.
- **SQL stored-procedure path** (older, still present): several SPs `INSERT dbo.ConfigAuditLog`
directly — e.g. `Published`/`RolledBack`/`NodeApplied`/`ExternalIdReleased`/`CrossClusterNamespaceAttempt`
in `Migrations/20260417215224_StoredProcedures.cs:151,217,351,407,504`. These use `SUSER_SNAME()`
as `Principal`, set `ClusterId`/`GenerationId`, write a **bare** `EventType` (no `Category:Action`
split), and leave `EventId`/`CorrelationId` NULL.
**Query / UI** — the only read surface is the Admin UI page
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor`
(`@page "/clusters/{ClusterId}/audit"`, `[Authorize]`, lines 1-2). It reads the latest
`PageSize = 200` rows (line 69) **filtered by `ClusterId`**, newest-first (`OnInitializedAsync`,
lines 74-82), and renders Timestamp / Principal / Event(Type) / Node / Correlation(first 8 hex) /
Details columns (lines 38-58). Tested in
`tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs`: count-threshold
flush (lines 26-41), in-buffer dedup of duplicate EventIds (lines 45-62), `PostStop` flush
(lines 66-81), and the column mapping incl. `EventType == "Config:Edit"` and `NodeId == "node-a"`
(lines 85-104).
> Load-bearing gotcha: the actor path **never sets `ClusterId`** (lines 75-84), but the UI filters
> on `ClusterId` (`ClusterAudit.razor:78`). So today the cluster-scoped view surfaces the
> stored-procedure rows; structured `AuditEvent` rows written by the actor (which carry the host in
> `NodeId`, not `ClusterId`) won't appear under a cluster. Worth flagging during normalization.
## 2. Mapping to the canonical `AuditEvent`
Target = `ZB.MOM.WW.Audit.AuditEvent` (built in parallel). OtOpcUa's existing `AuditEvent` is
already almost field-for-field aligned; the only synthesized field is `Outcome`.
| Canonical field | OtOpcUa source | Mapping |
|---|---|---|
| `Guid EventId` | `AuditEvent.EventId` | Direct. Already the idempotency key (buffer key + `UX_ConfigAuditLog_EventId`). |
| `DateTimeOffset OccurredAtUtc` | `AuditEvent.OccurredAtUtc` (`DateTime`) | Direct; widen `DateTime`(UTC) → `DateTimeOffset`. |
| `string Actor` | `AuditEvent.Actor` | Direct (→ `ConfigAuditLog.Principal`). At Auth adoption this becomes the `ZB.MOM.WW.Auth` principal. |
| `string Action` | `AuditEvent.Action` (+ `Category`) | Direct. Today persisted as `"{Category}:{Action}"` in `EventType`; canonical keeps `Action` and `Category` separate. |
| `AuditOutcome Outcome` | *(none)* | **Derived** from the EventType vocabulary, not stored today. `OpcUaAccessDenied`/`CrossClusterNamespaceAttempt``Denied`; the config-write verbs → `Success`. No explicit `Failure` value exists yet (a failed flush is dropped, not recorded as an event). |
| `string? Category` | `AuditEvent.Category` | Direct (e.g. `"Config"`). |
| `string? Target` | *(none)* | No dedicated field today; the closest is `SourceNode``NodeId` (the acting host) or details. Leave null or carry the affected object in `DetailsJson`. |
| `string? SourceNode` | `AuditEvent.SourceNode` (`NodeId.Value`) | Direct — the logical cluster node / host name (NOT an OPC UA NodeId). Currently lands in `ConfigAuditLog.NodeId`. |
| `Guid? CorrelationId` | `AuditEvent.CorrelationId` (`CorrelationId.Value`) | Direct. |
| `string? DetailsJson` | `AuditEvent.DetailsJson` | Direct; carries everything else (incl. `ClusterId`/`GenerationId`, which today are separate columns on the SP path). |
## 3. Adoption plan → `ZB.MOM.WW.Audit`
**Effort: medium.** OtOpcUa is the *donor* design for the canonical record, so most of the work is
re-pointing types and bridging two persistence conventions, not redesigning the pipeline.
**Replace with the shared library:**
- `Commons/Messages/Audit/AuditEvent.cs` → the canonical `ZB.MOM.WW.Audit.AuditEvent`. Add the new
`Outcome` field (derive it at every emit site from the EventType vocabulary, e.g.
`OpcUaAccessDenied → Denied`); keep `Category`/`Action`/`SourceNode`/`CorrelationId` as-is. Decide
whether `SourceNode`/`CorrelationId` carry the Commons value-types or the canonical primitives at
the seam (likely a thin adapter at construction).
- `AuditWriterActor` → implement the library's `IAuditWriter` (keep the actor as OtOpcUa's
Akka-cluster-singleton transport/batching adapter behind that seam; the 500/5s batching,
PreRestart/PostStop flush, and two-layer dedup stay bespoke per §"left per-project").
**Keep bespoke (thin adapter only):**
- Transport — the cluster-broadcast → singleton `AuditWriterActor`, batching, and flush triggers.
- Storage — the `ConfigAuditLog` EF entity, indexes, and `UX_ConfigAuditLog_EventId` idempotency
index. Map the canonical record onto the existing columns; add an `Outcome` column (or fold it into
`EventType`/`DetailsJson` if a schema change is undesirable). `ClusterId`/`GenerationId` remain
OtOpcUa-specific columns fed via `DetailsJson` or kept as side columns.
- Domain vocabulary — the EventType strings (`DraftCreated`, `Published`, `OpcUaAccessDenied`, …)
and the `Category:Action` composition convention.
- Query/UI — `ClusterAudit.razor` and its `ClusterId` filter.
**Reconcile, not extract:**
- The **two producers** (Akka `AuditEvent` path vs. SQL stored-procedure `INSERT`s using
`SUSER_SNAME()`). The SP path bypasses the canonical record entirely and writes a different
column convention (bare `EventType`, NULL `EventId`/`CorrelationId`, populated
`ClusterId`/`GenerationId`). Adopting the library does not by itself unify these; either route the
SP events through the actor or accept that SP rows stay non-idempotent and absent from the
`EventId` dedup guarantee. Flag for the normalization spec.
- The **`ClusterId`-filter / actor-never-sets-`ClusterId`** mismatch noted in §1 — fix when the
query surface is normalized so structured `AuditEvent` rows are discoverable by cluster.
@@ -0,0 +1,162 @@
# Audit — current state: ScadaBridge
Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET; solution `ZB.MOM.WW.ScadaBridge.slnx`.
Audit code centers on the dedicated `ZB.MOM.WW.ScadaBridge.AuditLog` project, with the shared
record + seams living in `ZB.MOM.WW.ScadaBridge.Commons`. All paths relative to repo root.
Verified 2026-06-01.
**By far the largest audit implementation in the family** — a full who-did-what pipeline
across a site SQLite hot-path and a central MS SQL store, with forwarding, reconciliation,
purge, partition maintenance, redaction, CLI export, hash-chain verify (v1 stub), and a Blazor
UI. **Key finding: ScadaBridge is already at the target.** It already has an `IAuditWriter`
best-effort seam (near-identical to the canonical contract) and an `IAuditPayloadFilter`
redaction seam (= the library's `IAuditRedactor`, just renamed). Adoption is *align, don't
replace* — mostly naming alignment; the enormous transport/storage/CLI/UI stays bespoke.
## 1. How it works today
### The record — `AuditEvent` (~25 fields)
`src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs:22` — a `sealed record`,
append-only, "single source of truth for AuditLog (#23) rows." Far richer than the canonical
10-field event. Notable fields:
- Identity / correlation: `EventId` (idempotency key, `:25`), `CorrelationId` (per-op
lifecycle, `:68`), `ExecutionId` (per-run, `:75`), `ParentExecutionId` (spawner link, `:82`).
- Classification: `Channel` (`:62`), `Kind` (`:65`), `Status` (`:109`) — the domain enums (below).
- Provenance: `SourceSiteId` (`:85`), `SourceNode` (`:94`, stamped from `INodeIdentityProvider`),
`SourceInstanceId` (`:97`), `SourceScript` (`:100`), `Actor` (`:103`), `Target` (`:106`).
- Outcome detail: `HttpStatus` (`:112`), `DurationMs` (`:115`), `ErrorMessage` (`:118`),
`ErrorDetail` (`:121`).
- Payload: `RequestSummary` / `ResponseSummary` (truncated+redacted, `:124`/`:127`),
`PayloadTruncated` (`:130`), `Extra` (free-form JSON, `:133`).
- Lifecycle plumbing: `IngestedAtUtc` (null on site, stamped at central ingest, `:52`),
`ForwardState` (site-only, null on central, `:136`).
**UTC-forcing init-setters.** `OccurredAtUtc` (`:39`) and `IngestedAtUtc` (`:52`) keep a backing
field and call `DateTime.SpecifyKind(value, DateTimeKind.Utc)` on assignment, so a value built
from a literal or rehydrated from a SQL Server `datetime2` column (which strips `Kind` on the
wire) cannot leak downstream as `Unspecified`/local. The record uses `DateTime` (not
`DateTimeOffset`) deliberately, to match the partitioned `datetime2` column shape (`:9-21`).
### Domain vocabulary — four enums
`src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/`:
- `AuditChannel.cs:7` — trust boundary crossed: `ApiOutbound`, `DbOutbound`, `Notification`,
`ApiInbound`.
- `AuditKind.cs:8` — specific event within a channel: `ApiCall`, `ApiCallCached`, `DbWrite`,
`DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`,
`CachedSubmit`, `CachedResolve`. Cached variants emit multiple rows per operation.
- `AuditStatus.cs:8` — lifecycle status of the row: `Submitted`, `Forwarded`, `Attempted`,
`Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`.
- `AuditForwardState.cs:9` — site-local forwarding state (central rows leave null): `Pending`,
`Forwarded`, `Reconciled`. The site retention purge MUST NOT drop a `Pending` row.
### The writer seam — `IAuditWriter` (best-effort, never aborts the action)
`src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs:10` — boundary-side
abstraction: `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)` (`:18`). The
contract is explicit and matches the canonical seam almost word-for-word: **"Failures must NEVER
abort the user-facing action"** (`:8`), best-effort, "implementations must swallow/log internal
failures rather than propagating them to the calling boundary code" (`:13-14`).
### The redaction seam — `IAuditPayloadFilter` (pure, never throws)
`src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs:22``AuditEvent Apply(
AuditEvent rawEvent)` (`:30`). Filters an event between construction and persistence:
truncates oversized payloads, redacts headers/body/SQL params, sets `PayloadTruncated`.
**Pure function** returning a filtered COPY via `with` expressions, and **MUST NOT throw**
on internal failure it over-redacts and increments the `AuditRedactionFailure` health metric
(`:11-20`, `:26-28`). This is exactly the canonical `IAuditRedactor` under a different name.
Two implementations: `DefaultAuditPayloadFilter.cs:56` (full truncation + header/body/SQL
redaction with live options) and `SafeDefaultAuditPayloadFilter.cs:19` (always-safe fallback —
header-only redaction, over-redacts on parse failure, `:42-59`).
### Transport / storage / pipeline — stays per-project
The `ZB.MOM.WW.ScadaBridge.AuditLog` project is split into `Site/`, `Central/`, `Payload/`, and
`Configuration/`. This is the bespoke half and is **not** a candidate for extraction; cited here
only to show the scale around the common core:
- **Site hot-path:** `Site/SqliteAuditWriter.cs:32` (`IAuditWriter` over an owned `SqliteConnection`
fed by a bounded `Channel<T>` drained on a background task, so script-thread callers never block
on disk I/O; first-write-wins on duplicate `EventId`). `Site/FallbackAuditWriter.cs:28` composes
the SQLite writer with a drop-oldest `RingBufferFallback` so a primary failure never bubbles out.
`Site/Telemetry/` forwards rows to central over Akka `ClusterClient`.
- **Central ingest/store:** `Central/CentralAuditWriter.cs:40` (`ICentralAuditWriter`, direct MS SQL
write for central-originated events, per-call EF scope, idempotent `InsertIfNotExistsAsync`,
swallows every exception per "alog.md §13"). `Central/AuditLogIngestActor.cs:46` batches site
telemetry; `Central/SiteAuditReconciliationActor.cs:68` periodically pulls to catch dropped
forwards; `Central/AuditLogPurgeActor.cs:58` enforces retention; `Central/AuditLogPartitionMaintenanceService.cs:55`
manages the partitioned table.
- **CLI:** `CLI/Commands/AuditCommands.cs:12` builds `export` (`:137`, formats `csv`/`jsonl`/`parquet`)
and `verify-chain` (`:226`). Hash-chain verify is currently a **v1 no-op stub**
`CLI/Commands/AuditVerifyChainHelpers.cs:6-10` ("v1 is a no-op").
- **UI:** Blazor pages under `CentralUI/Components/Pages/Audit/` (e.g. `AuditLogPage.razor:1`,
gated by `[Authorize(Policy = AuthorizationPolicies.OperationalAudit)]`) plus drill-down
components in `CentralUI/Components/Audit/`.
- **Wiring:** `AuditLog/ServiceCollectionExtensions.cs:59` `AddAuditLog(...)`, `:316`
`AddAuditLogCentralMaintenance(...)`.
## 2. Mapping to the canonical record
Target (`ZB.MOM.WW.Audit`, being built): `record AuditEvent { Guid EventId; DateTimeOffset
OccurredAtUtc; string Actor; string Action; AuditOutcome Outcome; string? Category; string?
Target; string? SourceNode; Guid? CorrelationId; string? DetailsJson; }`. ScadaBridge's record is
a strict superset — the canonical fields map directly; the rich extras collapse into `DetailsJson`.
| Canonical field | ScadaBridge source | Notes |
|---|---|---|
| `EventId` (Guid) | `AuditEvent.EventId` | Direct; same idempotency-key role. |
| `OccurredAtUtc` (DateTimeOffset) | `AuditEvent.OccurredAtUtc` (`DateTime`, UTC-forced) | Type bridge `DateTime`(Utc)↔`DateTimeOffset`; semantics identical. |
| `Actor` (string) | `AuditEvent.Actor` (nullable) | Direct; ScadaBridge allows null (system-originated rows). |
| `Action` (string) | `AuditEvent.Kind` (+`Channel`) | Derive a stable action string, e.g. `{Channel}.{Kind}` (`ApiOutbound.ApiCall`). |
| `Outcome` (Success/Failure/Denied) | `AuditEvent.Status` | `Delivered`→Success; `Failed`/`Parked`/`Discarded`→Failure; `InboundAuthFailure`(Kind)→Denied; in-flight `Submitted`/`Forwarded`/`Attempted` collapse to the last-known terminal state when projecting. |
| `Category` (string?) | `AuditEvent.Channel` | The coarse bucket; pairs with `Action` above. |
| `Target` (string?) | `AuditEvent.Target` | Direct. |
| `SourceNode` (string?) | `AuditEvent.SourceNode` | Direct (`node-a`/`central-b`/…). |
| `CorrelationId` (Guid?) | `AuditEvent.CorrelationId` | Direct (per-op lifecycle id). |
| `DetailsJson` (string?) | `ExecutionId`, `ParentExecutionId`, `SourceSiteId`, `SourceInstanceId`, `SourceScript`, `HttpStatus`, `DurationMs`, `ErrorMessage`, `ErrorDetail`, `RequestSummary`, `ResponseSummary`, `PayloadTruncated`, `Extra`, `IngestedAtUtc`, `ForwardState` | The ~15 rich/plumbing fields serialize into the canonical `DetailsJson` extension. |
The canonical record is a lossy *projection* of ScadaBridge's — fine for cross-project
reporting, but ScadaBridge keeps its full record as the storage shape (the partitioned SQL
schema, forwarding state, and reconciliation all depend on the extra columns).
## 3. Adoption plan → `ZB.MOM.WW.Audit`
**Posture: align, don't replace.** ScadaBridge is the reference implementation the shared
library is being extracted *from*; it already has both seams. Adoption is mostly renaming and
contract-confirmation, with a deliberately small touched surface and a large blast radius if
done carelessly. **Priority: LOW. Blast radius: HIGH.**
**Align (small, naming-level):**
- **Rename the redaction seam to match the contract.** `IAuditPayloadFilter` → adopt
`ZB.MOM.WW.Audit.IAuditRedactor` (`AuditEvent Apply(AuditEvent)` — identical signature and
pure/never-throws contract). Either alias `IAuditPayloadFilter : IAuditRedactor` during
transition or rename outright; `DefaultAuditPayloadFilter` / `SafeDefaultAuditPayloadFilter`
implement it unchanged. See [`../../shared-contract/`](../../shared-contract/).
- **Confirm the writer contract matches.** `IAuditWriter.WriteAsync(AuditEvent, CancellationToken
= default)` is already byte-for-byte the canonical signature, and the "never abort the
user-facing action" wording matches. The only delta is the **record type**: the library's
`IAuditWriter` is typed on the *canonical* 10-field `AuditEvent`, while ScadaBridge's is typed on
its ~25-field record. Resolve by either (a) keeping ScadaBridge's writer on its own rich record
and adopting only the library's *interface name + outcome enum*, or (b) having the shared seam be
generic over the event type. **Recommended: (a)** — adopt the canonical `AuditOutcome` enum and
the interface naming, but keep the bespoke `AuditEvent` as ScadaBridge's storage record, since the
whole transport/partition/forwarding layer is built on its extra columns. (Best-practice fit: this
is the minimal-coupling option — share the contract, not the schema.)
**Keep bespoke (the large, untouched majority):**
- The entire `Site/` (SQLite hot-path + ring-buffer fallback + telemetry forwarder) and `Central/`
(ingest / reconcile / purge / partition maintenance) pipeline.
- The `AuditEvent` rich record itself, the four domain enums (`AuditChannel`/`AuditKind`/
`AuditStatus`/`AuditForwardState`), CLI `export`/`verify-chain`, and the Blazor audit UI.
- The redaction *policy* (`DefaultAuditPayloadFilter` options, per-target overrides) — only the
interface name is shared, not the implementation.
**Net:** ScadaBridge converges by renaming one interface and adopting the canonical `AuditOutcome`
enum + the `Kind`/`Channel`→`Action`/`Category` and ``→`DetailsJson` projection for any
cross-project reporting. No transport, storage, CLI, or UI is replaced. Sequencing and the
cross-project gap list live in [`../../GAPS.md`](../../GAPS.md); the canonical target is
[`../../spec/SPEC.md`](../../spec/SPEC.md).
@@ -0,0 +1,153 @@
# Proposed shared library: `ZB.MOM.WW.Audit`
A contract on paper — the public surface to extract so the three projects stop
re-implementing audit-event capture with incompatible shapes. Realizes
[`../spec/SPEC.md`](../spec/SPEC.md).
**Not yet created.** Reference implementations already exist: ScadaBridge's
`IAuditWriter`/`IAuditPayloadFilter` (already at target shape), mxaccessgw
structured-log audit trail, OtOpcUa admin-UI audit log.
## Package (.NET 10)
```
ZB.MOM.WW.Audit # the single package: event record, seams, helpers, DI wiring
```
Single package, single DLL. Only non-BCL dependency:
`Microsoft.Extensions.DependencyInjection.Abstractions` (for `AddZbAudit`).
Published to the Gitea NuGet feed; SemVer.
| Package (→ DLL) | Transitive deps | OtOpcUa | mxaccessgw | ScadaBridge |
|---|---|---|---|---|
| `ZB.MOM.WW.Audit` | `Microsoft.Extensions.DependencyInjection.Abstractions` | ✅ | ✅ | ✅ |
All three auth-bearing processes are .NET 10 — the x86/net48 mxaccessgw worker does
no audit emission, so net48 multi-targeting is **not** required.
## `AuditEvent` record and `AuditOutcome` enum
```csharp
public sealed record AuditEvent {
public required Guid EventId { get; init; }
public required DateTimeOffset OccurredAtUtc { get; init; } // normalized to UTC on assignment
public required string Actor { get; init; }
public required string Action { get; init; }
public required AuditOutcome Outcome { get; init; }
public string? Category { get; init; }
public string? Target { get; init; }
public string? SourceNode { get; init; }
public Guid? CorrelationId { get; init; }
public string? DetailsJson { get; init; }
}
public enum AuditOutcome { Success, Failure, Denied }
```
`OccurredAtUtc` is the only field with a normalization contract: any value assigned
is coerced to UTC (via `ToUniversalTime()`). All other fields are caller-supplied and
carried through without transformation by the library internals.
## Seams
### `IAuditWriter`
```csharp
public interface IAuditWriter
{
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
}
```
**Hard contract:**
- Best-effort delivery. The implementation **MUST swallow all internal failures** and
**MUST NOT throw** to the caller. A write that fails silently is preferable to
a write that crashes the calling thread or kills a request pipeline.
- `CancellationToken` is respected for cooperative cancellation but a cancellation
does not constitute a contract violation; the implementation may choose to complete
a partially-written event anyway.
### `IAuditRedactor`
```csharp
public interface IAuditRedactor
{
AuditEvent Apply(AuditEvent rawEvent);
}
```
**Hard contract:**
- Pure function (no I/O, no side effects).
- **MUST NOT throw.** On any internal failure the implementation must over-redact
(e.g. replace the affected field with a sentinel such as `"[redacted]"`) rather
than propagate the exception. Lossier output is always preferable to a thrown
exception reaching the caller.
## Shipped helpers (concrete)
### Redactors
| Type | Behaviour |
|---|---|
| `NullAuditRedactor` | Identity — returns the event unchanged. Registered as the default by `AddZbAudit`. |
| `TruncatingAuditRedactor` | Caps `DetailsJson` and `Target` to a configurable maximum length and appends a marker (e.g. `"…"`) when truncated. Never throws. Configured via `TruncatingAuditRedactorOptions`. |
| `TruncatingAuditRedactorOptions` | Options record for `TruncatingAuditRedactor`: `MaxDetailsJsonLength`, `MaxTargetLength`, `TruncationMarker`. |
### Writers
| Type | Behaviour |
|---|---|
| `NoOpAuditWriter` | Discards every event. Registered as the default by `AddZbAudit`; consumer replaces with a real writer. |
| `CompositeAuditWriter` | Fan-out: forwards each event to an ordered list of inner `IAuditWriter` instances. A failing inner writer is swallowed (per the `IAuditWriter` contract) — it does **not** abort the remaining writers in the list. |
| `RedactingAuditWriter` | Decorator: calls `IAuditRedactor.Apply` on the event, then delegates the redacted event to an inner `IAuditWriter`. Separates the redaction concern from any concrete writer. |
## DI wiring
```csharp
public static IServiceCollection AddZbAudit(this IServiceCollection services);
```
Registers defaults via `TryAdd` so any prior consumer registration wins:
- `IAuditRedactor``NullAuditRedactor` (singleton)
- `IAuditWriter``NoOpAuditWriter` (singleton)
A consumer that registers its own `IAuditWriter` (e.g. a Serilog-backed writer or a
`CompositeAuditWriter`) before or after calling `AddZbAudit` will see its registration
respected. `AddZbAudit` does **not** clear or override existing registrations.
## Relationship to Telemetry (`ILogRedactor`)
`IAuditRedactor` mirrors Telemetry.Serilog's `ILogRedactor` in shape and naming — same
single-method contract, same "pure, must not throw, over-redact on failure" semantics —
so that a future `ZB.MOM.WW.Hosting` aggregator package can wire both behind a single
configuration surface without an impedance mismatch.
`ZB.MOM.WW.Audit` has **no dependency** on `ZB.MOM.WW.Telemetry` or any Serilog package.
The alignment is intentional design convergence; the independence is a hard boundary.
## What stays in each consumer
OtOpcUa: admin-UI audit sink (Blazor event handler → `IAuditWriter`), `Category`
constants specific to OPC UA operations.
mxaccessgw: gRPC interceptor that captures actor/action from call metadata; constraint-aware
`Category` tagging; `DetailsJson` serialization of gateway-specific payloads.
ScadaBridge: site-scoped `SourceNode` population; `ManagementActor` enforcement callbacks;
`IAuditPayloadFilter``IAuditRedactor` migration (shape is already equivalent — adoption
is a near-zero-effort rename).
## Open contract questions
1. **Batching**: a `WriteBatchAsync(IEnumerable<AuditEvent>, CancellationToken)` overload on
`IAuditWriter` may be warranted once a database-backed writer is in use. Defer until
the first consumer demonstrates the need; batching can be added without breaking the
existing single-event surface.
2. **Structured `DetailsJson`**: confirm whether callers should supply raw JSON strings or
whether a typed `TDetails` generic overload (serialized internally) is cleaner. The
current `string?` keeps the library dependency-free but shifts serialization to the caller.
3. **`CompositeAuditWriter` error policy**: decide whether per-writer failure should be
observable (e.g. an optional `ILogger<CompositeAuditWriter>`) or always silently dropped.
Logging the failure is diagnostic-friendly but adds a logging dependency.
See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.
+94
View File
@@ -0,0 +1,94 @@
# Canonical event model (standardized)
Status: **Standardized**. The org-wide audit record + outcome enum every sister project maps onto.
This is the reference companion to [`SPEC.md`](SPEC.md) (mirroring auth's `CANONICAL-ROLES.md` /
theme's `DESIGN-TOKENS.md`): the field-by-field canonical record, the `AuditOutcome` definition with
which app states map onto each value, and the full per-project mapping table. The shared library
defines exactly this record; each project **projects its native record onto it** at the seam.
## The canonical record
```csharp
namespace ZB.MOM.WW.Audit;
public sealed record AuditEvent
{
// REQUIRED core — who / what / when / outcome
public required Guid EventId { get; init; } // idempotency key
public required DateTimeOffset OccurredAtUtc { get; init; } // normalized to UTC
public required string Actor { get; init; } // who — = ZB.MOM.WW.Auth principal at adoption
public required string Action { get; init; } // what — verb / event-type string
public required AuditOutcome Outcome { get; init; } // Success | Failure | Denied
// OPTIONAL common
public string? Category { get; init; } // subsystem / grouping bucket
public string? Target { get; init; } // on-what (resource / method / connection)
public string? SourceNode { get; init; } // emitting logical node / host
public Guid? CorrelationId { get; init; } // join to originating request / workflow
// EXTENSION — everything project-specific, as JSON
public string? DetailsJson { get; init; }
}
public enum AuditOutcome { Success, Failure, Denied }
```
### Field-by-field
| Field | Req? | Type | Meaning | Notes |
|---|:-:|---|---|---|
| `EventId` | yes | `Guid` | Idempotency key | Backs at-least-once transports: OtOpcUa's filtered-unique `EventId` index, ScadaBridge's first-write-wins. MxGateway has none today → **generate at write time**. |
| `OccurredAtUtc` | yes | `DateTimeOffset` | When it happened, UTC | MxGateway already uses `DateTimeOffset`. OtOpcUa / ScadaBridge store UTC-forced `DateTime` and widen at the mapping boundary. |
| `Actor` | yes | `string` | Who acted | SHOULD be the `ZB.MOM.WW.Auth` principal ([`SPEC.md`](SPEC.md) §4). Kept a `string` (no Auth dependency). Keyless events use a `"system"` / `"cli"` fallback rather than empty. |
| `Action` | yes | `string` | What was done (verb / event-type) | Carries each app's domain verb: OtOpcUa `EventType`, MxGateway `EventType`, ScadaBridge `{Channel}.{Kind}`. |
| `Outcome` | yes | `AuditOutcome` | Success / Failure / Denied | **New normalized field — no app stores it today; each derives it** (see below). |
| `Category` | no | `string?` | Coarse subsystem / grouping | OtOpcUa `Category` (`"Config"`); MxGateway constant `"ApiKey"`; ScadaBridge `Channel`. |
| `Target` | no | `string?` | The object acted on | ScadaBridge `Target` (direct). OtOpcUa / MxGateway have no dedicated field → null or fold into `DetailsJson`. |
| `SourceNode` | no | `string?` | Emitting logical node / host | OtOpcUa `SourceNode` (a logical node name, **not** an OPC UA NodeId); ScadaBridge `SourceNode`; MxGateway `RemoteAddress`. |
| `CorrelationId` | no | `Guid?` | Join to originating request / workflow | OtOpcUa / ScadaBridge direct; MxGateway has none today (left null). |
| `DetailsJson` | no | `string?` | Extension bag — all project-specific data | Must be valid JSON where stored (OtOpcUa enforces this with a CHECK constraint). Absorbs each app's surplus columns. |
## `AuditOutcome` — definition and app-state mapping
Three values, deliberately minimal — enough to normalize denials and failures without importing any
app's full taxonomy. `Outcome` is **derived** at each emit site (no app persists it today; OtOpcUa
encodes it implicitly in `EventType`, MxGateway in the event-type literal, ScadaBridge in `Status`):
| `AuditOutcome` | Meaning | OtOpcUa (`EventType`) | MxGateway (event type) | ScadaBridge (`AuditStatus` / `AuditKind`) |
|---|---|---|---|---|
| **`Success`** | The action completed | config-write verbs — `DraftCreated`, `DraftEdited`, `Published`, `RolledBack`, `NodeApplied`, `CredentialAdded`, `ClusterCreated`, `NodeAdded`, `ExternalIdReleased`, … | key-lifecycle — `init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key` + all `dashboard-*` | `Status = Delivered` |
| **`Failure`** | The action was attempted and failed | *(none today — a failed actor flush is dropped, not recorded as an event)* | *(none emitted today)* | `Status ∈ { Failed, Parked, Discarded }` |
| **`Denied`** | The action was rejected by authorization / policy | `OpcUaAccessDenied`, `CrossClusterNamespaceAttempt` | `constraint-denied` | `Kind = InboundAuthFailure` |
Notes:
- **OtOpcUa has no `Failure` source.** Its vocabulary only distinguishes success-verbs from
access-denials; an internal write failure is dropped (best-effort), not emitted as an event. So
OtOpcUa produces only `Success` / `Denied` until/unless it adds failure events.
- **MxGateway emits only `Success` / `Denied`** today (no failure events; authentication
success/failure is surfaced as gRPC status, not persisted — see its current-state doc).
- **ScadaBridge in-flight states** (`Submitted` / `Forwarded` / `Attempted`) are not terminal; when
projecting to a single `Outcome` they collapse to the last-known terminal state. `Skipped` is not a
user-facing outcome and is excluded from the canonical projection.
## Per-project mapping table (canonical ← native record)
Consolidated from the three current-state docs. "Direct" = field exists with the same role; the
right-hand notes flag the type bridges and synthesized fields.
| Canonical field | OtOpcUa `AuditEvent` (8 fields) | MxGateway `ApiKeyAuditRecord` (6 fields) | ScadaBridge `AuditEvent` (~25 fields) |
|---|---|---|---|
| `EventId` | `EventId` — direct (idempotency key) | **generate** new `Guid` (only `AuditId` rowid exists) | `EventId` — direct |
| `OccurredAtUtc` | `OccurredAtUtc` (`DateTime` UTC) → widen | `CreatedUtc` (store-assigned `DateTimeOffset`) — direct | `OccurredAtUtc` (`DateTime` UTC-forced) → widen |
| `Actor` | `Actor` — direct | `KeyId` (nullable → `"system"`/`"cli"` fallback) | `Actor` (nullable on system rows) |
| `Action` | `Action` (persisted as `"{Category}:{Action}"`) | `EventType` — direct | `{Channel}.{Kind}` (e.g. `ApiOutbound.ApiCall`) |
| `Outcome` | **derive** from `EventType` | **derive**: `constraint-denied``Denied`, else `Success` | **derive** from `Status` (+`InboundAuthFailure``Denied`) |
| `Category` | `Category` (`"Config"`) | constant `"ApiKey"` | `Channel` |
| `Target` | — none — (null or via `DetailsJson`) | — none — (`commandKind`/`target` embedded in `Details` text) | `Target` — direct |
| `SourceNode` | `SourceNode` (logical node, `NodeId.Value`) | `RemoteAddress` (dashboard path only) | `SourceNode` — direct |
| `CorrelationId` | `CorrelationId` (`CorrelationId.Value`) — direct | — none — | `CorrelationId` — direct |
| `DetailsJson` | `DetailsJson` — direct (also `ClusterId`/`GenerationId` on the SP path) | `Details` (plain string → store as-is or wrap) | the ~15 rich/plumbing fields (`ExecutionId`, `SourceSiteId`, `HttpStatus`, `DurationMs`, `ErrorMessage`, `RequestSummary`, `ResponseSummary`, `PayloadTruncated`, `Extra`, `ForwardState`, …) serialize here |
The canonical record is a **lossy projection**: it is sufficient for cross-project reporting, but each
project keeps its native record as the storage shape — ScadaBridge especially, whose partitioned SQL
schema, forwarding state, and reconciliation depend on the extra columns ([`SPEC.md`](SPEC.md) §5).
+146
View File
@@ -0,0 +1,146 @@
# Audit — normalized target spec
Status: **Draft**. The single design the sister projects converge on. Derived from the three
code-verified current-state docs (`../current-state/`) and the locked design
(`../../../docs/plans/2026-06-01-audit-component-design.md`). Goal is *path to shared code*
(`../shared-contract/ZB.MOM.WW.Audit.md`), so each normalized section maps to a shared library seam.
## 0. Normalized vs left-per-project
**Normalized here** (the shared `ZB.MOM.WW.Audit` library):
- **The canonical `AuditEvent` record** — required core (`EventId`, `OccurredAtUtc`, `Actor`,
`Action`, `Outcome`) + optional common (`Category`, `Target`, `SourceNode`, `CorrelationId`) +
the `DetailsJson` extension bag. The full field-by-field reference is [`EVENT-MODEL.md`](EVENT-MODEL.md).
- **`AuditOutcome`** — the 3-value `Success | Failure | Denied` enum (§3). This is a *new*
normalized field every app derives; see [`EVENT-MODEL.md`](EVENT-MODEL.md) for the per-app derivation.
- **The two seams** — `IAuditWriter` (best-effort, never throws to caller, §1) and `IAuditRedactor`
(pure, never throws, over-redacts on failure, §2).
**Explicitly NOT normalized** (domain-specific / divergent — keep per project):
- **Transport & storage** — OtOpcUa's Akka cluster-broadcast → singleton `AuditWriterActor` (batch
500 / 5 s, two-layer dedup) over `ConfigAuditLog`; MxGateway's SQLite `IApiKeyAuditStore` append +
list-recent; ScadaBridge's site-SQLite hot-path → central MS SQL ingest / reconcile / purge /
partition-maintenance / hash-chain pipeline. The shared core carries no Akka / EF / SQLite /
Serilog dependency; its only non-BCL dependency is `Microsoft.Extensions.DependencyInjection.Abstractions`
(for `AddZbAudit`).
- **Domain vocabulary** — ScadaBridge's `Channel` / `Kind` / `Status` / `ForwardState` enums and
OtOpcUa's `EventType` strings (`DraftCreated`, `Published`, `OpcUaAccessDenied`, …). These map
*into* `Action` / `Category` / `Outcome` / `DetailsJson`; they do not leak into the shared type.
- **Query / CLI / UI / export** surfaces (OtOpcUa `ClusterAudit.razor`; ScadaBridge `export` /
`verify-chain` CLI + Blazor audit pages; MxGateway's unused `ListRecentAsync`).
- **Each app's redaction *policy*** — *which* fields/commands/payloads are sensitive. Only the
`IAuditRedactor` *seam* is shared; the `Default` / `Safe` filter behaviour stays per-project.
> **Scope of the producer path.** OtOpcUa has **two producers** writing the same `ConfigAuditLog`
> table — the structured Akka `AuditEvent` path *and* older SQL stored procedures that `INSERT`
> directly (`SUSER_SNAME()`, bare `EventType`, NULL `EventId`). Normalization targets the
> **structured producer path** (the one that builds an `AuditEvent`), not every SQL insert; the SP
> path stays per-project and is a reconcile item, not an extraction item (`../GAPS.md`).
## 1. The writer contract — `IAuditWriter` (best-effort)
```csharp
public interface IAuditWriter
{
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
}
```
Audit is a side-channel, never on the critical path. The hard rule:
- **`WriteAsync` MUST NOT throw to the caller.** An implementation swallows/logs its own internal
failures; a failed write **must never abort the user-facing action** it is recording. (ScadaBridge's
seam already states this almost word-for-word: "Failures must NEVER abort the user-facing action.")
- Idempotency is carried by `EventId`, so retries and at-least-once transports are safe (OtOpcUa's
filtered-unique `EventId` index and ScadaBridge's first-write-wins are both honoured by this key).
- Delivery is at-most-once *as a contract* — a writer MAY drop on failure (OtOpcUa drops a failed
batch; ScadaBridge's ring-buffer fallback drops oldest). Durability is a per-project transport
decision, not part of this seam.
Shipped helpers (the only concrete writers): `NoOpAuditWriter` (discards — tests / disabled audit),
`CompositeAuditWriter` (fans out to N writers; **one writer throwing does not stop the others**), and
`RedactingAuditWriter` (decorator: applies the redactor, then delegates to an inner writer).
## 2. The redactor contract — `IAuditRedactor` (never throws)
```csharp
public interface IAuditRedactor
{
AuditEvent Apply(AuditEvent rawEvent);
}
```
A pure projection from a raw event to a safe one, applied between event construction and the writer
chain. The hard rule:
- **`Apply` MUST NOT throw.** On any internal failure it **over-redacts** (returns a strictly safer
event) rather than propagating — a redactor that throws would either crash the audit path or leak
the unredacted event. (ScadaBridge's `SafeDefaultAuditPayloadFilter` is the reference: header-only
redaction, over-redacts on parse failure.)
- It is a **pure function** returning a filtered *copy* (via `with`); it does not mutate the input or
perform I/O.
The seam is **aligned-but-independent** with Telemetry's `ILogRedactor` — same shape and naming
discipline so a future `ZB.MOM.WW.Hosting` aggregator wires both with one mental model — but there is
**no cross-package dependency**. Shipped helpers: `NullAuditRedactor` (identity — the default when no
policy is configured) and `TruncatingAuditRedactor` (caps `DetailsJson` / `Target` to a configured
max + sets a truncation marker; never throws). The *secret-field policy* (which fields/commands are
sensitive) stays per-project via composition.
## 3. `AuditOutcome` — the new normalized field
`Outcome` is in the **required core**, but **no app stores it today** — each encodes outcome
implicitly and must **derive** it at adoption (this is the one genuinely new field):
- **OtOpcUa** — derived from the `EventType` vocabulary (`OpcUaAccessDenied` /
`CrossClusterNamespaceAttempt``Denied`; config-write verbs → `Success`).
- **MxGateway** — `constraint-denied``Denied`; key-lifecycle events → `Success`.
- **ScadaBridge** — `AuditStatus``Outcome` (`Delivered``Success`; `Failed` / `Parked` /
`Discarded``Failure`; `InboundAuthFailure` kind → `Denied`).
The three values normalize denials and failures across the family without importing any app's full
taxonomy. The enum definition and the complete state-by-state mapping live in [`EVENT-MODEL.md`](EVENT-MODEL.md).
## 4. The hinge — audit closes the loop on Auth
Every audit row's `Actor` is the *who*, which is exactly the identity the **Auth** component already
normalizes (LDAP/GLAuth principal, API-key name). Auth is the read side ("who is this and what may
they do"); audit is the write side ("who did what"). The spec ties them by stating:
- **`Actor` SHOULD be the `ZB.MOM.WW.Auth` principal** at adoption time.
- But `Actor` is **kept as a plain `string`** in the contract, so the library carries **no dependency
on `ZB.MOM.WW.Auth`**. (MxGateway's keyless events — `init-db` / `list-keys` — supply a `"system"` /
`"cli"` fallback rather than leaving the required field empty.)
This mirrors Auth's own decision to keep audit *read* inside `OBSERVE` and audit *export* inside
`ADMINISTER` rather than minting a separate auditor role: the two components share a vocabulary, not a
dependency.
## 5. ScadaBridge is already at the target
ScadaBridge already ships **both** seams: an `IAuditWriter` whose best-effort contract matches
word-for-word, and an `IAuditPayloadFilter` that *is* the canonical `IAuditRedactor` under a different
name (identical `AuditEvent Apply(AuditEvent)` signature, pure / never-throws / over-redacts). The
library essentially **lifts ScadaBridge's seams**.
The one real (non-naming) decision is the **writer's record type**: the canonical `IAuditWriter` is
typed on the 10-field `AuditEvent`; ScadaBridge's writer is typed on its ~25-field record.
> **Resolution (recommended):** share the **interface *name* + the `AuditOutcome` enum**, not the
> record schema. ScadaBridge keeps its rich ~25-field record as its **storage shape** (its whole
> transport / partition / forwarding / reconciliation layer is built on the extra columns), and maps
> to the canonical 10-field record **only at cross-app reporting boundaries**. This is the
> minimal-coupling option — share the contract, not the schema — and avoids making the shared seam
> generic over the event type. ScadaBridge therefore converges by **renaming one interface** and
> adopting `AuditOutcome`, with no transport / storage / CLI / UI change.
## 6. Acceptance (what "converged" means)
A project is converged when: (a) its structured audit-producer path constructs the canonical
`AuditEvent` (with `Outcome` derived per §3) and persists via an implementation of `IAuditWriter`;
(b) any redaction runs through an `IAuditRedactor`; (c) `Actor` carries the `ZB.MOM.WW.Auth` principal
where one exists (string fallback otherwise); with its transport, storage, domain vocabulary, query
surfaces, and redaction *policy* unchanged. Per-project deltas and the adoption backlog are in
[`../GAPS.md`](../GAPS.md); the proposed library API is [`../shared-contract/ZB.MOM.WW.Audit.md`](../shared-contract/ZB.MOM.WW.Audit.md).
+8 -2
View File
@@ -49,7 +49,7 @@ Three different, incompatible approaches mean you can't scrape or dashboard the
(+ `Commons` `OtOpcUaTelemetry`); MxGateway `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs`
(~470 LOC, no exporter); ScadaBridge `OpenTelemetry.Api` dep only (no instrumentation).
### 3. Audit — shared event *model* + writer seam
### 3. Audit — shared event *model* + writer seam ✅ DONE
All three audit; the who-did-what record (actor / action / target / time / correlationId /
detailsJson) is genuinely common, and it **closes the loop on the Auth component** (audit's
"who" = identity). Transport differs (Akka cluster vs SQLite vs none), so extract only the
@@ -58,6 +58,9 @@ ScadaBridge's pipeline is ~3k LOC.
- Evidence: OtOpcUa `Commons/Messages/Audit/AuditEvent.cs` + `ControlPlane/Audit/AuditWriterActor.cs`
+ `Configuration/Entities/ConfigAuditLog.cs`; ScadaBridge `src/ZB.MOM.WW.ScadaBridge.AuditLog/`
(site + central) + `Commons/Entities/Audit/`; MxGateway `Security/Authentication/SqliteApiKeyAuditStore.cs`.
- **Delivered:** shared library built at [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) (1 nupkg @ 0.1.0);
design at [`components/audit/`](components/audit/); adoption backlog in
[`components/audit/GAPS.md`](components/audit/GAPS.md).
### Strategic — the gRPC `.proto` break surface
[`CLAUDE.md`](CLAUDE.md) names these as *the* cross-repo break surface ("a green build in one
@@ -70,7 +73,10 @@ cross-repo interop checks, distinct from the others.
### Tier 2 (good, with caveats)
- **Logging — `ZB.MOM.WW.Logging`:** strong overlap (Serilog bootstrap + enrichers SiteId/NodeRole/Host
+ correlation scope), but MxGateway uses MS.Extensions.Logging — step 1 is converging on Serilog.
Natural to bundle with Telemetry as "observability".
Natural to bundle with Telemetry as "observability". **Note:** the logging-family work for this
family is being delivered as `ZB.MOM.WW.Telemetry.Serilog` by the health/observability normalization
pass ([`components/health/`](components/health/)), not as a standalone `ZB.MOM.WW.Logging` lib —
a separate Logging candidate is not expected.
- **Config validation conventions:** all three use IOptions + `IValidateOptions` + `ValidateOnStart`;
a shared validation base + startup-validation helper is reusable and pairs with the Auth options pattern.