diff --git a/CLAUDE.md b/CLAUDE.md index cf52da7..5fa1c12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/ZB.MOM.WW.Audit/.gitignore b/ZB.MOM.WW.Audit/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/ZB.MOM.WW.Audit/.gitignore @@ -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 diff --git a/ZB.MOM.WW.Audit/Directory.Build.props b/ZB.MOM.WW.Audit/Directory.Build.props new file mode 100644 index 0000000..b234d68 --- /dev/null +++ b/ZB.MOM.WW.Audit/Directory.Build.props @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + latest + 0.1.0 + true + + diff --git a/ZB.MOM.WW.Audit/Directory.Packages.props b/ZB.MOM.WW.Audit/Directory.Packages.props new file mode 100644 index 0000000..bc720bb --- /dev/null +++ b/ZB.MOM.WW.Audit/Directory.Packages.props @@ -0,0 +1,15 @@ + + + true + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Audit/README.md b/ZB.MOM.WW.Audit/README.md new file mode 100644 index 0000000..2e1bac5 --- /dev/null +++ b/ZB.MOM.WW.Audit/README.md @@ -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 diff --git a/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.slnx b/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.slnx new file mode 100644 index 0000000..50d2e28 --- /dev/null +++ b/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ZB.MOM.WW.Audit/build/pack.sh b/ZB.MOM.WW.Audit/build/pack.sh new file mode 100755 index 0000000..68d2b48 --- /dev/null +++ b/ZB.MOM.WW.Audit/build/pack.sh @@ -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 diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs new file mode 100644 index 0000000..5de8e2d --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditEvent.cs @@ -0,0 +1,50 @@ +namespace ZB.MOM.WW.Audit; + +/// +/// Canonical, transport-agnostic audit record — who did what, when, with what outcome. +/// Required core + optional common fields + a extension bag. Each +/// sister app maps its own record onto this; domain vocabularies (channels/kinds/event-types) +/// map into // and are not +/// modelled here. See scadaproj/components/audit/spec/EVENT-MODEL.md. +/// +public sealed record AuditEvent +{ + /// Idempotency key uniquely identifying this audit event. + public required Guid EventId { get; init; } + + /// When the audited action occurred. Normalized to UTC on assignment. + /// Participates in record value-equality as a normalized instant: two events whose + /// OccurredAtUtc denote the same instant at different offsets (e.g. 12:00+05:00 and + /// 07:00Z) compare equal and share a hash code. Relevant to consumers that dedup/key on + /// value-equality. + public required DateTimeOffset OccurredAtUtc + { + get => _occurredAtUtc; + init => _occurredAtUtc = value.ToUniversalTime(); + } + private readonly DateTimeOffset _occurredAtUtc; + + /// Who performed the action (identity string; the ZB.MOM.WW.Auth principal at adoption). + public required string Actor { get; init; } + + /// What was done — a verb/event-type string. + public required string Action { get; init; } + + /// Normalized outcome. + public required AuditOutcome Outcome { get; init; } + + /// Optional subsystem/grouping for the action. + public string? Category { get; init; } + + /// Optional target of the action (resource/method/connection). + public string? Target { get; init; } + + /// Optional node that emitted the event. + public string? SourceNode { get; init; } + + /// Optional correlation id joining this row to its originating request/workflow. + public Guid? CorrelationId { get; init; } + + /// Optional JSON extension carrying project-specific fields. + public string? DetailsJson { get; init; } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs new file mode 100644 index 0000000..536561e --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditOutcome.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// Normalized outcome of an audited action. +public enum AuditOutcome +{ + /// The action completed successfully. + Success, + /// The action failed due to an error. + Failure, + /// The action was rejected by authentication/authorization. + Denied, +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditServiceCollectionExtensions.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditServiceCollectionExtensions.cs new file mode 100644 index 0000000..52bc006 --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/AuditServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace ZB.MOM.WW.Audit; + +/// DI helpers for ZB.MOM.WW.Audit. +public static class AuditServiceCollectionExtensions +{ + /// + /// Registers safe defaults — and — + /// using TryAdd so a consumer that has already registered a real writer/redactor wins. Consumers + /// compose / around their own sink. + /// + public static IServiceCollection AddZbAudit(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(NullAuditRedactor.Instance); + services.TryAddSingleton(NoOpAuditWriter.Instance); + return services; + } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs new file mode 100644 index 0000000..6cb271d --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/CompositeAuditWriter.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.Audit; + +/// Fans an event out to several writers. Best-effort: a failing writer does not stop the others. +/// A failing writer's exception is swallowed so the fan-out drains and the caller is never +/// aborted — but is re-thrown so cancellation is honored. +public sealed class CompositeAuditWriter : IAuditWriter +{ + private readonly IReadOnlyList _inner; + + /// Creates a composite over the given writers. + public CompositeAuditWriter(IEnumerable inner) + { + ArgumentNullException.ThrowIfNull(inner); + _inner = inner.ToArray(); + } + + /// + 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 */ } + } + } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs new file mode 100644 index 0000000..f8f5f2c --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditRedactor.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.Audit; + +/// +/// Filters an 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 ILogRedactor so a future +/// ZB.MOM.WW.Hosting aggregator can wire both consistently; intentionally has no dependency on it. +/// +public interface IAuditRedactor +{ + /// Apply the configured truncation/redaction policy and return a filtered copy. + AuditEvent Apply(AuditEvent rawEvent); +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs new file mode 100644 index 0000000..a3e6619 --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/IAuditWriter.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// +/// Best-effort sink for 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. +/// +public interface IAuditWriter +{ + /// Persist an audit event. Best-effort; must not throw to the caller. + Task WriteAsync(AuditEvent evt, CancellationToken ct = default); +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs new file mode 100644 index 0000000..b737d9f --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NoOpAuditWriter.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// Writer that discards events. Default when audit is disabled, and useful in tests. +public sealed class NoOpAuditWriter : IAuditWriter +{ + /// Shared singleton instance. + public static readonly NoOpAuditWriter Instance = new(); + private NoOpAuditWriter() { } + + /// + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask; +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs new file mode 100644 index 0000000..d688b3a --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/NullAuditRedactor.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// Identity redactor — returns the event unchanged. The default when no policy is configured. +public sealed class NullAuditRedactor : IAuditRedactor +{ + /// Shared singleton instance. + public static readonly NullAuditRedactor Instance = new(); + private NullAuditRedactor() { } + + /// + public AuditEvent Apply(AuditEvent rawEvent) => rawEvent; +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs new file mode 100644 index 0000000..4ff4794 --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/RedactingAuditWriter.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.Audit; + +/// Decorator: applies an , then delegates to an inner . +public sealed class RedactingAuditWriter : IAuditWriter +{ + private readonly IAuditRedactor _redactor; + private readonly IAuditWriter _inner; + + /// Creates the decorator around using . + public RedactingAuditWriter(IAuditRedactor redactor, IAuditWriter inner) + { + ArgumentNullException.ThrowIfNull(redactor); + ArgumentNullException.ThrowIfNull(inner); + _redactor = redactor; + _inner = inner; + } + + /// + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(evt); + return _inner.WriteAsync(_redactor.Apply(evt), ct); + } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs new file mode 100644 index 0000000..4ca6cbd --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactor.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.Audit; + +/// +/// Redactor that caps oversized and . +/// 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. +/// +public sealed class TruncatingAuditRedactor : IAuditRedactor +{ + private readonly TruncatingAuditRedactorOptions _options; + + /// Creates the redactor with the given options (defaults when null). + public TruncatingAuditRedactor(TruncatingAuditRedactorOptions? options = null) + => _options = options ?? new TruncatingAuditRedactorOptions(); + + /// + 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); + } +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs new file mode 100644 index 0000000..0c44aba --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/TruncatingAuditRedactorOptions.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Audit; + +/// Caps for . +public sealed class TruncatingAuditRedactorOptions +{ + /// Max length of before truncation. Default 4096. + public int MaxDetailsJsonLength { get; set; } = 4096; + /// Max length of before truncation. Default 512. + public int MaxTargetLength { get; set; } = 512; + /// Marker appended to a truncated value. Default "…[truncated]". + public string TruncationMarker { get; set; } = "…[truncated]"; +} diff --git a/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj new file mode 100644 index 0000000..7951fde --- /dev/null +++ b/ZB.MOM.WW.Audit/src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + + + true + ZB.MOM.WW.Audit + ZB.MOM.WW + Canonical audit event model + best-effort writer and redactor seams for the ZB.MOM.WW SCADA family. + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit + + + + + diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs new file mode 100644 index 0000000..f5e17f8 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditEventTests.cs @@ -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()); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..d733786 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/AuditServiceCollectionExtensionsTests.cs @@ -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(sp.GetRequiredService()); + Assert.IsType(sp.GetRequiredService()); + } + + [Fact] + public void Does_not_override_a_preregistered_writer() + { + var services = new ServiceCollection(); + services.AddSingleton(new CompositeAuditWriter(System.Array.Empty())); + var sp = services.AddZbAudit().BuildServiceProvider(); + Assert.IsType(sp.GetRequiredService()); + } + + [Fact] + public void Does_not_override_a_preregistered_redactor() + { + var services = new ServiceCollection(); + services.AddSingleton(new TruncatingAuditRedactor()); + var sp = services.AddZbAudit().BuildServiceProvider(); + Assert.IsType(sp.GetRequiredService()); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs new file mode 100644 index 0000000..1faef9c --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/CompositeAuditWriterTests.cs @@ -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(() => sut.WriteAsync(Evt())); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs new file mode 100644 index 0000000..f44fea1 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NoOpAuditWriterTests.cs @@ -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); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs new file mode 100644 index 0000000..4bca581 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/NullAuditRedactorTests.cs @@ -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)); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs new file mode 100644 index 0000000..34dc53d --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/RedactingAuditWriterTests.cs @@ -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); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs new file mode 100644 index 0000000..02ba387 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/TruncatingAuditRedactorTests.cs @@ -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); + } +} diff --git a/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj new file mode 100644 index 0000000..9ed57d2 --- /dev/null +++ b/ZB.MOM.WW.Audit/tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj @@ -0,0 +1,18 @@ + + + false + + + + + + + + + + + + + + + diff --git a/components/README.md b/components/README.md index e09b54c..b8103bd 100644 --- a/components/README.md +++ b/components/README.md @@ -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`. diff --git a/components/audit/GAPS.md b/components/audit/GAPS.md new file mode 100644 index 0000000..cb82e30 --- /dev/null +++ b/components/audit/GAPS.md @@ -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). diff --git a/components/audit/README.md b/components/audit/README.md new file mode 100644 index 0000000..0b5eba5 --- /dev/null +++ b/components/audit/README.md @@ -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//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). diff --git a/components/audit/current-state/mxaccessgw/CURRENT-STATE.md b/components/audit/current-state/mxaccessgw/CURRENT-STATE.md new file mode 100644 index 0000000..ef3f6c3 --- /dev/null +++ b/components/audit/current-state/mxaccessgw/CURRENT-STATE.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. diff --git a/components/audit/current-state/otopcua/CURRENT-STATE.md b/components/audit/current-state/otopcua/CURRENT-STATE.md new file mode 100644 index 0000000..0fbde6e --- /dev/null +++ b/components/audit/current-state/otopcua/CURRENT-STATE.md @@ -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(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` 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. diff --git a/components/audit/current-state/scadabridge/CURRENT-STATE.md b/components/audit/current-state/scadabridge/CURRENT-STATE.md new file mode 100644 index 0000000..3f33b5a --- /dev/null +++ b/components/audit/current-state/scadabridge/CURRENT-STATE.md @@ -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` 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). diff --git a/components/audit/shared-contract/ZB.MOM.WW.Audit.md b/components/audit/shared-contract/ZB.MOM.WW.Audit.md new file mode 100644 index 0000000..a120bff --- /dev/null +++ b/components/audit/shared-contract/ZB.MOM.WW.Audit.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, 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`) 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. diff --git a/components/audit/spec/EVENT-MODEL.md b/components/audit/spec/EVENT-MODEL.md new file mode 100644 index 0000000..9cc6181 --- /dev/null +++ b/components/audit/spec/EVENT-MODEL.md @@ -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). diff --git a/components/audit/spec/SPEC.md b/components/audit/spec/SPEC.md new file mode 100644 index 0000000..1d81310 --- /dev/null +++ b/components/audit/spec/SPEC.md @@ -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). diff --git a/upcoming.md b/upcoming.md index cde7775..3d8ee86 100644 --- a/upcoming.md +++ b/upcoming.md @@ -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.