Merge feat/zb-mom-ww-audit: Audit normalization component + ZB.MOM.WW.Audit (0.1.0)
# Conflicts: # CLAUDE.md # components/README.md
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Extensions -->
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<!-- Test -->
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# pack.sh — produce the ZB.MOM.WW.Audit NuGet package into ./artifacts.
|
||||
set -euo pipefail
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical, transport-agnostic audit record — who did what, when, with what outcome.
|
||||
/// Required core + optional common fields + a <see cref="DetailsJson"/> extension bag. Each
|
||||
/// sister app maps its own record onto this; domain vocabularies (channels/kinds/event-types)
|
||||
/// map into <see cref="Action"/>/<see cref="Category"/>/<see cref="DetailsJson"/> and are not
|
||||
/// modelled here. See scadaproj/components/audit/spec/EVENT-MODEL.md.
|
||||
/// </summary>
|
||||
public sealed record AuditEvent
|
||||
{
|
||||
/// <summary>Idempotency key uniquely identifying this audit event.</summary>
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
/// <summary>When the audited action occurred. Normalized to UTC on assignment.</summary>
|
||||
/// <remarks>Participates in record value-equality as a normalized instant: two events whose
|
||||
/// <c>OccurredAtUtc</c> denote the same instant at different offsets (e.g. <c>12:00+05:00</c> and
|
||||
/// <c>07:00Z</c>) compare equal and share a hash code. Relevant to consumers that dedup/key on
|
||||
/// <see cref="AuditEvent"/> value-equality.</remarks>
|
||||
public required DateTimeOffset OccurredAtUtc
|
||||
{
|
||||
get => _occurredAtUtc;
|
||||
init => _occurredAtUtc = value.ToUniversalTime();
|
||||
}
|
||||
private readonly DateTimeOffset _occurredAtUtc;
|
||||
|
||||
/// <summary>Who performed the action (identity string; the ZB.MOM.WW.Auth principal at adoption).</summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>What was done — a verb/event-type string.</summary>
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>Normalized outcome.</summary>
|
||||
public required AuditOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>Optional subsystem/grouping for the action.</summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>Optional target of the action (resource/method/connection).</summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>Optional node that emitted the event.</summary>
|
||||
public string? SourceNode { get; init; }
|
||||
|
||||
/// <summary>Optional correlation id joining this row to its originating request/workflow.</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>Optional JSON extension carrying project-specific fields.</summary>
|
||||
public string? DetailsJson { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Normalized outcome of an audited action.</summary>
|
||||
public enum AuditOutcome
|
||||
{
|
||||
/// <summary>The action completed successfully.</summary>
|
||||
Success,
|
||||
/// <summary>The action failed due to an error.</summary>
|
||||
Failure,
|
||||
/// <summary>The action was rejected by authentication/authorization.</summary>
|
||||
Denied,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>DI helpers for ZB.MOM.WW.Audit.</summary>
|
||||
public static class AuditServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers safe defaults — <see cref="NullAuditRedactor"/> and <see cref="NoOpAuditWriter"/> —
|
||||
/// using TryAdd so a consumer that has already registered a real writer/redactor wins. Consumers
|
||||
/// compose <see cref="RedactingAuditWriter"/>/<see cref="CompositeAuditWriter"/> around their own sink.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddZbAudit(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.TryAddSingleton<IAuditRedactor>(NullAuditRedactor.Instance);
|
||||
services.TryAddSingleton<IAuditWriter>(NoOpAuditWriter.Instance);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Fans an event out to several writers. Best-effort: a failing writer does not stop the others.</summary>
|
||||
/// <remarks>A failing writer's exception is swallowed so the fan-out drains and the caller is never
|
||||
/// aborted — but <see cref="OperationCanceledException"/> is re-thrown so cancellation is honored.</remarks>
|
||||
public sealed class CompositeAuditWriter : IAuditWriter
|
||||
{
|
||||
private readonly IReadOnlyList<IAuditWriter> _inner;
|
||||
|
||||
/// <summary>Creates a composite over the given writers.</summary>
|
||||
public CompositeAuditWriter(IEnumerable<IAuditWriter> inner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inner);
|
||||
_inner = inner.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
foreach (var writer in _inner)
|
||||
{
|
||||
try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { throw; } // honor cancellation; do not swallow
|
||||
catch { /* best-effort seam: a failing writer must not stop the others or the caller */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Filters an <see cref="AuditEvent"/> between construction and persistence — truncates oversized
|
||||
/// fields and scrubs sensitive content. Pure function: returns a filtered COPY and MUST NOT throw
|
||||
/// (over-redact on internal failure). Shaped to mirror Telemetry's <c>ILogRedactor</c> so a future
|
||||
/// ZB.MOM.WW.Hosting aggregator can wire both consistently; intentionally has no dependency on it.
|
||||
/// </summary>
|
||||
public interface IAuditRedactor
|
||||
{
|
||||
/// <summary>Apply the configured truncation/redaction policy and return a filtered copy.</summary>
|
||||
AuditEvent Apply(AuditEvent rawEvent);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort sink for <see cref="AuditEvent"/>s. Implementations MUST swallow/log internal
|
||||
/// failures rather than propagating them — a failed audit write must never abort the
|
||||
/// user-facing action that produced it.
|
||||
/// </summary>
|
||||
public interface IAuditWriter
|
||||
{
|
||||
/// <summary>Persist an audit event. Best-effort; must not throw to the caller.</summary>
|
||||
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Writer that discards events. Default when audit is disabled, and useful in tests.</summary>
|
||||
public sealed class NoOpAuditWriter : IAuditWriter
|
||||
{
|
||||
/// <summary>Shared singleton instance.</summary>
|
||||
public static readonly NoOpAuditWriter Instance = new();
|
||||
private NoOpAuditWriter() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Identity redactor — returns the event unchanged. The default when no policy is configured.</summary>
|
||||
public sealed class NullAuditRedactor : IAuditRedactor
|
||||
{
|
||||
/// <summary>Shared singleton instance.</summary>
|
||||
public static readonly NullAuditRedactor Instance = new();
|
||||
private NullAuditRedactor() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuditEvent Apply(AuditEvent rawEvent) => rawEvent;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Decorator: applies an <see cref="IAuditRedactor"/>, then delegates to an inner <see cref="IAuditWriter"/>.</summary>
|
||||
public sealed class RedactingAuditWriter : IAuditWriter
|
||||
{
|
||||
private readonly IAuditRedactor _redactor;
|
||||
private readonly IAuditWriter _inner;
|
||||
|
||||
/// <summary>Creates the decorator around <paramref name="inner"/> using <paramref name="redactor"/>.</summary>
|
||||
public RedactingAuditWriter(IAuditRedactor redactor, IAuditWriter inner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(redactor);
|
||||
ArgumentNullException.ThrowIfNull(inner);
|
||||
_redactor = redactor;
|
||||
_inner = inner;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
return _inner.WriteAsync(_redactor.Apply(evt), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Redactor that caps oversized <see cref="AuditEvent.DetailsJson"/> and <see cref="AuditEvent.Target"/>.
|
||||
/// Never throws — over-redacts (drops DetailsJson) on internal failure. The secret-field policy
|
||||
/// (which fields are sensitive) stays per-project; compose this with a project redactor as needed.
|
||||
/// </summary>
|
||||
public sealed class TruncatingAuditRedactor : IAuditRedactor
|
||||
{
|
||||
private readonly TruncatingAuditRedactorOptions _options;
|
||||
|
||||
/// <summary>Creates the redactor with the given options (defaults when null).</summary>
|
||||
public TruncatingAuditRedactor(TruncatingAuditRedactorOptions? options = null)
|
||||
=> _options = options ?? new TruncatingAuditRedactorOptions();
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
return rawEvent with
|
||||
{
|
||||
Target = Truncate(rawEvent.Target, _options.MaxTargetLength),
|
||||
DetailsJson = Truncate(rawEvent.DetailsJson, _options.MaxDetailsJsonLength),
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hard contract: never throw. Over-redact on internal failure.
|
||||
return rawEvent with { DetailsJson = null };
|
||||
}
|
||||
}
|
||||
|
||||
private string? Truncate(string? value, int max)
|
||||
{
|
||||
if (value is null || value.Length <= max) return value;
|
||||
var marker = _options.TruncationMarker;
|
||||
if (marker.Length >= max) return marker[..max];
|
||||
return string.Concat(value.AsSpan(0, max - marker.Length), marker);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Caps for <see cref="TruncatingAuditRedactor"/>.</summary>
|
||||
public sealed class TruncatingAuditRedactorOptions
|
||||
{
|
||||
/// <summary>Max length of <see cref="AuditEvent.DetailsJson"/> before truncation. Default 4096.</summary>
|
||||
public int MaxDetailsJsonLength { get; set; } = 4096;
|
||||
/// <summary>Max length of <see cref="AuditEvent.Target"/> before truncation. Default 512.</summary>
|
||||
public int MaxTargetLength { get; set; } = 512;
|
||||
/// <summary>Marker appended to a truncated value. Default "…[truncated]".</summary>
|
||||
public string TruncationMarker { get; set; } = "…[truncated]";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Audit</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>Canonical audit event model + best-effort writer and redactor seams for the ZB.MOM.WW SCADA family.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class AuditEventTests
|
||||
{
|
||||
private static AuditEvent Minimal() => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "alice",
|
||||
Action = "ConfigPublished",
|
||||
Outcome = AuditOutcome.Success,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Required_core_fields_round_trip()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var evt = Minimal() with { EventId = id, Actor = "svc", Action = "ApiCall", Outcome = AuditOutcome.Denied };
|
||||
Assert.Equal(id, evt.EventId);
|
||||
Assert.Equal("svc", evt.Actor);
|
||||
Assert.Equal("ApiCall", evt.Action);
|
||||
Assert.Equal(AuditOutcome.Denied, evt.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OccurredAtUtc_is_normalized_to_utc()
|
||||
{
|
||||
var local = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5));
|
||||
var evt = Minimal() with { OccurredAtUtc = local };
|
||||
Assert.Equal(TimeSpan.Zero, evt.OccurredAtUtc.Offset);
|
||||
Assert.Equal(local.UtcDateTime, evt.OccurredAtUtc.UtcDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optional_fields_default_to_null()
|
||||
{
|
||||
var evt = Minimal();
|
||||
Assert.Null(evt.Category);
|
||||
Assert.Null(evt.Target);
|
||||
Assert.Null(evt.SourceNode);
|
||||
Assert.Null(evt.CorrelationId);
|
||||
Assert.Null(evt.DetailsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Records_with_same_values_are_equal()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var when = DateTimeOffset.UtcNow;
|
||||
AuditEvent Make() => new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
|
||||
Assert.Equal(Make(), Make());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_instant_at_different_offset_compares_equal()
|
||||
{
|
||||
// Guards the UTC-normalizing init-setter: if OccurredAtUtc is ever "simplified" back to a
|
||||
// plain auto-property, these two (same instant, different offset) would stop comparing equal.
|
||||
var id = Guid.NewGuid();
|
||||
var utc = new DateTimeOffset(2026, 6, 1, 7, 0, 0, TimeSpan.Zero);
|
||||
var plus5 = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5)); // same instant as utc
|
||||
AuditEvent With(DateTimeOffset when) =>
|
||||
new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
|
||||
Assert.Equal(With(utc), With(plus5));
|
||||
Assert.Equal(With(utc).GetHashCode(), With(plus5).GetHashCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class AuditServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registers_null_redactor_and_noop_writer_by_default()
|
||||
{
|
||||
var sp = new ServiceCollection().AddZbAudit().BuildServiceProvider();
|
||||
Assert.IsType<NullAuditRedactor>(sp.GetRequiredService<IAuditRedactor>());
|
||||
Assert.IsType<NoOpAuditWriter>(sp.GetRequiredService<IAuditWriter>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Does_not_override_a_preregistered_writer()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IAuditWriter>(new CompositeAuditWriter(System.Array.Empty<IAuditWriter>()));
|
||||
var sp = services.AddZbAudit().BuildServiceProvider();
|
||||
Assert.IsType<CompositeAuditWriter>(sp.GetRequiredService<IAuditWriter>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Does_not_override_a_preregistered_redactor()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IAuditRedactor>(new TruncatingAuditRedactor());
|
||||
var sp = services.AddZbAudit().BuildServiceProvider();
|
||||
Assert.IsType<TruncatingAuditRedactor>(sp.GetRequiredService<IAuditRedactor>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class CompositeAuditWriterTests
|
||||
{
|
||||
private sealed class RecordingWriter : IAuditWriter
|
||||
{
|
||||
public int Count;
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Count++; return Task.CompletedTask; }
|
||||
}
|
||||
private sealed class ThrowingWriter : IAuditWriter
|
||||
{
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("boom");
|
||||
}
|
||||
private sealed class CancellingWriter : IAuditWriter
|
||||
{
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new OperationCanceledException();
|
||||
}
|
||||
|
||||
private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
|
||||
|
||||
[Fact]
|
||||
public async Task Fans_out_to_all_writers()
|
||||
{
|
||||
var a = new RecordingWriter(); var b = new RecordingWriter();
|
||||
await new CompositeAuditWriter(new IAuditWriter[] { a, b }).WriteAsync(Evt());
|
||||
Assert.Equal(1, a.Count);
|
||||
Assert.Equal(1, b.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_failing_writer_does_not_stop_the_others()
|
||||
{
|
||||
var after = new RecordingWriter();
|
||||
var sut = new CompositeAuditWriter(new IAuditWriter[] { new ThrowingWriter(), after });
|
||||
await sut.WriteAsync(Evt()); // must not throw
|
||||
Assert.Equal(1, after.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_is_propagated_not_swallowed()
|
||||
{
|
||||
// OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed).
|
||||
var after = new RecordingWriter();
|
||||
var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after });
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => sut.WriteAsync(Evt()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class NoOpAuditWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAsync_completes_without_error()
|
||||
{
|
||||
var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
|
||||
await NoOpAuditWriter.Instance.WriteAsync(evt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class NullAuditRedactorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Apply_returns_input_unchanged()
|
||||
{
|
||||
var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "{\"k\":1}" };
|
||||
Assert.Same(evt, NullAuditRedactor.Instance.Apply(evt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class RedactingAuditWriterTests
|
||||
{
|
||||
private sealed class CapturingWriter : IAuditWriter
|
||||
{
|
||||
public AuditEvent? Last;
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Last = evt; return Task.CompletedTask; }
|
||||
}
|
||||
private sealed class StampRedactor : IAuditRedactor
|
||||
{
|
||||
public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = "redacted" };
|
||||
}
|
||||
|
||||
private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "secret" };
|
||||
|
||||
[Fact]
|
||||
public async Task Inner_writer_receives_the_redacted_event()
|
||||
{
|
||||
var inner = new CapturingWriter();
|
||||
var sut = new RedactingAuditWriter(new StampRedactor(), inner);
|
||||
await sut.WriteAsync(Evt());
|
||||
Assert.Equal("redacted", inner.Last!.DetailsJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace ZB.MOM.WW.Audit.Tests;
|
||||
|
||||
public class TruncatingAuditRedactorTests
|
||||
{
|
||||
private static AuditEvent Evt(string? details, string? target = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = "a", Action = "x", Outcome = AuditOutcome.Success,
|
||||
DetailsJson = details, Target = target,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Short_values_pass_through_unchanged()
|
||||
{
|
||||
var r = new TruncatingAuditRedactor(new() { MaxDetailsJsonLength = 100 });
|
||||
var evt = Evt("small");
|
||||
Assert.Equal("small", r.Apply(evt).DetailsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversized_details_are_truncated_with_marker()
|
||||
{
|
||||
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 10, TruncationMarker = "~" };
|
||||
var r = new TruncatingAuditRedactor(opts);
|
||||
var result = r.Apply(Evt(new string('x', 50)));
|
||||
Assert.Equal(10, result.DetailsJson!.Length);
|
||||
Assert.EndsWith("~", result.DetailsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversized_target_is_truncated()
|
||||
{
|
||||
var r = new TruncatingAuditRedactor(new() { MaxTargetLength = 5, TruncationMarker = "" });
|
||||
var result = r.Apply(Evt(null, target: "abcdefghij"));
|
||||
Assert.Equal(5, result.Target!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_fields_are_left_null()
|
||||
{
|
||||
var r = new TruncatingAuditRedactor();
|
||||
var result = r.Apply(Evt(null));
|
||||
Assert.Null(result.DetailsJson);
|
||||
Assert.Null(result.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marker_longer_than_max_clips_the_marker_itself()
|
||||
{
|
||||
// Misconfiguration: marker longer than the cap. Must not throw; clips to the first max chars.
|
||||
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 3, TruncationMarker = "…[truncated]" };
|
||||
var r = new TruncatingAuditRedactor(opts);
|
||||
var result = r.Apply(Evt(new string('x', 20)));
|
||||
Assert.Equal(3, result.DetailsJson!.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Audit\ZB.MOM.WW.Audit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,72 @@
|
||||
# Audit (who-did-what)
|
||||
|
||||
Status: **Draft**. Normalized component — path to shared code. Goal: converge the three
|
||||
sister projects onto a canonical `AuditEvent` record + `AuditOutcome` enum + two thin seams
|
||||
(`IAuditWriter`, `IAuditRedactor`), proposed as the `ZB.MOM.WW.Audit` library, while each
|
||||
project keeps its own transport, storage, domain vocabulary, and redaction policy.
|
||||
|
||||
- The one target: [`spec/SPEC.md`](spec/SPEC.md)
|
||||
- Canonical event model + field reference: [`spec/EVENT-MODEL.md`](spec/EVENT-MODEL.md)
|
||||
- The proposed shared library: [`shared-contract/ZB.MOM.WW.Audit.md`](shared-contract/ZB.MOM.WW.Audit.md)
|
||||
- Divergences + backlog: [`GAPS.md`](GAPS.md)
|
||||
- Current state, per project: [`current-state/`](current-state/)
|
||||
|
||||
## Why audit is a strong normalization candidate
|
||||
|
||||
All three projects record a structured who-did-what trail with an actor identity, an action
|
||||
verb, and a timestamp. Two (OtOpcUa + ScadaBridge) already have a named `AuditEvent` record
|
||||
with an `EventId` idempotency key, `Actor`, and `CorrelationId`. ScadaBridge already ships
|
||||
**both** canonical seams under slightly different names (`IAuditWriter` is byte-for-byte the
|
||||
spec; `IAuditPayloadFilter` is the canonical `IAuditRedactor`). OtOpcUa's record is almost
|
||||
field-for-field aligned. MxGateway has a narrow API-key-lifecycle log that maps cleanly.
|
||||
|
||||
The one new field across all three is `AuditOutcome` — no project stores it explicitly today;
|
||||
each encodes it implicitly and derives it at adoption. This is the bulk of the per-project
|
||||
work. Transport, storage, domain vocabulary, and redaction policy are **not** unified — each
|
||||
project keeps its own bespoke implementation behind the seam.
|
||||
|
||||
**Audit closes the loop on Auth.** Every audit row's `Actor` is exactly the identity that the
|
||||
`ZB.MOM.WW.Auth` component normalizes (LDAP/GLAuth principal, API-key name). The library keeps
|
||||
`Actor` as a plain `string` (no Auth dependency), but at adoption each emit site supplies the
|
||||
Auth principal.
|
||||
|
||||
**`IAuditRedactor` naming is aligned with Telemetry's `ILogRedactor`** — same shape and naming
|
||||
discipline so a future `ZB.MOM.WW.Hosting` aggregator wires both redactors with one mental
|
||||
model — but there is no cross-package dependency between the two libraries.
|
||||
|
||||
## Status by project
|
||||
|
||||
| Project | Audit today | Seams present | `AuditOutcome` | Adoption status |
|
||||
|---|---|---|---|---|
|
||||
| **OtOpcUa** | Akka cluster-broadcast `AuditEvent` → cluster-singleton `AuditWriterActor` (batch 500/5 s, two-layer dedup) over EF `ConfigAuditLog` (SQL Server). Also a legacy SQL stored-procedure write path (bare `EventType`, NULL `EventId`). Admin UI page `ClusterAudit.razor`. | No named `IAuditWriter` seam; no redactor seam. | Not stored — encoded in `EventType` strings (`OpcUaAccessDenied`/`CrossClusterNamespaceAttempt` → `Denied`; config-write verbs → `Success`). | Not started |
|
||||
| **MxAccessGateway** | Single SQLite-backed `IApiKeyAuditStore` / `ApiKeyAuditEntry` — key lifecycle (CLI + dashboard) + constraint denials only. No authn events persisted; no production read consumer. | Narrow custom seam (`IApiKeyAuditStore`); no general `IAuditWriter`; redaction is by-construction (secret never enters the record type). | Not stored — derived: `constraint-denied` → `Denied`; all others → `Success`. | Not started |
|
||||
| **ScadaBridge** | Full pipeline: site SQLite hot-path (`SqliteAuditWriter` + ring-buffer fallback) → Akka `ClusterClient` forwarder → central MS SQL (ingest / reconcile / purge / partition maintenance). Rich ~25-field `AuditEvent` record. CLI `export`/`verify-chain`; Blazor audit UI. | ✅ `IAuditWriter` (matches canonical contract word-for-word); ✅ `IAuditPayloadFilter` (= canonical `IAuditRedactor`, identical signature, pure/never-throws/over-redacts). | Not stored explicitly — derived from `Status` (`Delivered`→`Success`; `Failed`/`Parked`/`Discarded`→`Failure`; `Kind = InboundAuthFailure`→`Denied`). | Not started (align, don't replace) |
|
||||
|
||||
See each project's `current-state/<project>/CURRENT-STATE.md` for code-verified detail and
|
||||
adoption plan:
|
||||
|
||||
- [`current-state/otopcua/CURRENT-STATE.md`](current-state/otopcua/CURRENT-STATE.md)
|
||||
- [`current-state/mxaccessgw/CURRENT-STATE.md`](current-state/mxaccessgw/CURRENT-STATE.md)
|
||||
- [`current-state/scadabridge/CURRENT-STATE.md`](current-state/scadabridge/CURRENT-STATE.md)
|
||||
|
||||
## Normalized vs. left per-project
|
||||
|
||||
**Normalized (the shared `ZB.MOM.WW.Audit` library):** the canonical `AuditEvent` record
|
||||
(5 required fields + 4 optional common + `DetailsJson` extension bag); the `AuditOutcome`
|
||||
enum (`Success | Failure | Denied`); the `IAuditWriter` seam (best-effort, never throws to
|
||||
caller); the `IAuditRedactor` seam (pure, never throws, over-redacts on failure); shipped
|
||||
helpers (`NoOpAuditWriter`, `CompositeAuditWriter`, `RedactingAuditWriter`,
|
||||
`NullAuditRedactor`, `TruncatingAuditRedactor`). Library has no Akka / EF / SQLite / Serilog
|
||||
dependency; its only non-BCL dependency is `Microsoft.Extensions.DependencyInjection.Abstractions`.
|
||||
|
||||
**Left per-project (each project keeps these behind the seam):** transport and storage (Akka
|
||||
singleton + EF/SQL Server; SQLite; site-SQLite + central MS SQL + forwarding/reconcile
|
||||
pipeline); domain vocabulary (`EventType` strings / API-key event-type literals / `Channel` +
|
||||
`Kind` + `Status` enums); query, CLI, and UI surfaces (`ClusterAudit.razor`; `ListRecentAsync`;
|
||||
`export` / `verify-chain`; Blazor audit pages); redaction *policy* (which fields/payloads are
|
||||
sensitive — only the `IAuditRedactor` *seam* is shared).
|
||||
|
||||
> **Adoption is deferred this round.** The `ZB.MOM.WW.Audit` library is being designed and
|
||||
> the shared contract defined, but none of the three apps wire it in yet — exactly where
|
||||
> `ZB.MOM.WW.Auth` and `ZB.MOM.WW.Theme` sit today. The per-project adoption backlog is in
|
||||
> [`GAPS.md`](GAPS.md).
|
||||
@@ -0,0 +1,118 @@
|
||||
# Audit — current state: MxAccessGateway (`mxaccessgw`)
|
||||
|
||||
Repo: `~/Desktop/MxAccessGateway` (Gitea `mxaccessgw`). Stack: .NET 10 gateway (x64) + x86/net48 worker.
|
||||
Audit lives entirely in the **gateway** (.NET 10); the worker records nothing.
|
||||
All paths relative to repo root; audit code under `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`. Verified 2026-06-01.
|
||||
|
||||
This is the **narrowest** of the three implementations: a single SQLite-backed append-only log scoped
|
||||
to **API-key lifecycle and constraint denials**. There is no general-purpose audit abstraction, no
|
||||
separate redaction seam, and no CorrelationId. Read-back exists but has no production consumer today.
|
||||
|
||||
## How it works today
|
||||
|
||||
The audit log is one seam, `IApiKeyAuditStore`
|
||||
(`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs:6`), with exactly two
|
||||
operations: `AppendAsync(ApiKeyAuditEntry, ...)` (`IApiKeyAuditStore.cs:14`) and
|
||||
`ListRecentAsync(int count, ...)` (`IApiKeyAuditStore.cs:22`). Single implementation,
|
||||
`SqliteApiKeyAuditStore` (`SqliteApiKeyAuditStore.cs:5`), registered as a singleton in
|
||||
`AuthStoreServiceCollectionExtensions.cs:23` alongside the rest of the auth stores.
|
||||
|
||||
- **Append-side shape:** callers pass `ApiKeyAuditEntry(string? KeyId, string EventType, string? RemoteAddress, string? Details)`
|
||||
(`ApiKeyAuditEntry.cs:3`). The store sets the timestamp itself — `AppendAsync` writes
|
||||
`created_utc = DateTimeOffset.UtcNow.ToString("O")` (`SqliteApiKeyAuditStore.cs:20`), so the caller
|
||||
cannot supply the time and there is **no idempotency/event key** (the only identity is the DB
|
||||
`AUTOINCREMENT` rowid).
|
||||
- **Read-side shape:** `ListRecentAsync` returns `ApiKeyAuditRecord(long AuditId, string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details)`
|
||||
(`ApiKeyAuditRecord.cs:3`), ordered `audit_id DESC LIMIT $count` (`SqliteApiKeyAuditStore.cs:38-42`),
|
||||
returning `[]` for `count <= 0` (`SqliteApiKeyAuditStore.cs:29-32`).
|
||||
- **Storage:** SQLite, the same gateway-owned auth DB (`AuthSqliteConnectionFactory`, WAL; default
|
||||
`C:\ProgramData\MxGateway\gateway-auth.db`). Table `api_key_audit` is created by
|
||||
`SqliteAuthStoreMigrator.cs:95-102` — `audit_id INTEGER PRIMARY KEY AUTOINCREMENT, key_id TEXT NULL,
|
||||
event_type TEXT NOT NULL, remote_address TEXT NULL, created_utc TEXT NOT NULL, details TEXT NULL`,
|
||||
plus index `ix_api_key_audit_key_id_created_utc` (`SqliteAuthStoreMigrator.cs:107-108`). Table name
|
||||
constant `SqliteAuthSchema.ApiKeyAuditTable = "api_key_audit"` (`SqliteAuthSchema.cs:11`). The log is
|
||||
append-only: there is no update/delete/prune path.
|
||||
- **Producers (three, all in the gateway):**
|
||||
- **Admin CLI** `ApiKeyAdminCliRunner` — its private `AppendAuditAsync` (`ApiKeyAdminCliRunner.cs:153`)
|
||||
always passes `RemoteAddress: null` (`ApiKeyAdminCliRunner.cs:163`). Event types:
|
||||
`"init-db"` (`:48`), `"create-key"` (`:74`), `"list-keys"` (`:83`),
|
||||
`"revoke-key"` with details `revoked`/`not-found-or-already-revoked` (`:102`),
|
||||
`"rotate-key"` with details `rotated`/`not-found` (`:121`).
|
||||
- **Dashboard** `DashboardApiKeyManagementService` — its `AppendAuditAsync` (`:197`) captures
|
||||
`RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString()` (`:207`).
|
||||
Event types: `"dashboard-create-key"` (`:62`), `"dashboard-revoke-key"` (`:103`, details
|
||||
`revoked`/`not-found-or-already-revoked`), `"dashboard-rotate-key"` (`:145`, details `rotated`/`not-found`),
|
||||
`"dashboard-delete-key"` (`:187`, details `deleted`/`not-found-or-active`).
|
||||
- **Constraint denials** `ConstraintEnforcer.RecordDenialAsync` (`ConstraintEnforcer.cs:117`) writes
|
||||
`EventType: "constraint-denied"`, `RemoteAddress: null`, and `Details:
|
||||
$"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"` (`ConstraintEnforcer.cs:124-129`).
|
||||
This is the only "denial" event in the log.
|
||||
- **No authn events.** The verifier (`ApiKeyVerifier`) and the gRPC authorization interceptor
|
||||
(`GatewayGrpcAuthorizationInterceptor`) do **not** write to the audit store — authentication
|
||||
success/failure and `Unauthenticated`/`PermissionDenied` outcomes are surfaced as gRPC statuses and
|
||||
(per policy) discriminated for logging, but are not persisted as audit rows. So in practice the log
|
||||
records **key lifecycle (CLI + dashboard) + constraint denials**, not per-request authn outcomes.
|
||||
- **No separate redaction seam — scrubbing is structural, in the store/entry shape.** There is no
|
||||
redactor, scrubber, sanitizer, or masking helper. Safety comes from *what the entry type can carry*:
|
||||
`ApiKeyAuditEntry` has no field for a secret, and every caller passes only a `KeyId` (the public
|
||||
key identifier, never the secret), an event-type literal, and short hand-built `Details` strings —
|
||||
the secret/pepper never enters the audit path. This aligns with the repo policy that "API keys,
|
||||
passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs"
|
||||
(`CLAUDE.md:79`). Net: redaction is by construction, not a pluggable seam.
|
||||
- **Read-back has no production consumer.** `ListRecentAsync` is called only by tests
|
||||
(`SqliteAuthStoreTests`, `ApiKeyAdminCliRunnerTests`). The dashboard `ApiKeysPage.razor` mentions the
|
||||
audit log only in a delete-confirmation string (`ApiKeysPage.razor:321`) — it does **not** render it.
|
||||
There is no UI or RPC that surfaces audit history today.
|
||||
|
||||
## Mapping to the canonical record
|
||||
|
||||
Target: `ZB.MOM.WW.Audit`'s `AuditEvent { Guid EventId; DateTimeOffset OccurredAtUtc; string Actor;
|
||||
string Action; AuditOutcome Outcome; string? Category; string? Target; string? SourceNode;
|
||||
Guid? CorrelationId; string? DetailsJson; }` with `AuditOutcome ∈ { Success, Failure, Denied }`.
|
||||
|
||||
| `AuditEvent` field | Source today | Mapping note |
|
||||
|---|---|---|
|
||||
| `EventId` (Guid, required) | — none — | **Must be generated** at write time. `ApiKeyAuditRecord` has only the autoincrement `AuditId` (`ApiKeyAuditRecord.cs:4`); no idempotency key exists. |
|
||||
| `OccurredAtUtc` (required) | `CreatedUtc` (`ApiKeyAuditRecord.cs:8`), set as `DateTimeOffset.UtcNow` in the store (`SqliteApiKeyAuditStore.cs:20`) | Direct. Note: time is store-assigned today, not caller-supplied. |
|
||||
| `Actor` (required) | `KeyId` (`ApiKeyAuditRecord.cs:5`) | Nullable today (`init-db`/`list-keys` pass `null`); the canonical `Actor` is required, so a fallback (e.g. `"system"`/`"cli"`) is needed for keyless events. |
|
||||
| `Action` (required) | `EventType` (`ApiKeyAuditRecord.cs:6`) | Direct. CLI vocab: `init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key`; dashboard vocab: `dashboard-create-key`, `dashboard-revoke-key`, `dashboard-rotate-key`, `dashboard-delete-key`; plus `constraint-denied`. |
|
||||
| `Outcome` (required) | derived | `constraint-denied` → `Denied`; everything else → `Success` (no `Failure` events are emitted today). |
|
||||
| `Category` | — none — | Constant `"ApiKey"`. |
|
||||
| `Target` | — none as a field — | No structured target. (`ConstraintEnforcer` does embed `commandKind`/`target` inside `Details` text, but there is no dedicated column.) |
|
||||
| `SourceNode` | `RemoteAddress` (`ApiKeyAuditRecord.cs:7`) | Direct; populated only on the dashboard path (`DashboardApiKeyManagementService.cs:207`), `null` on CLI/constraint paths. |
|
||||
| `CorrelationId` | — none — | Not captured today. |
|
||||
| `DetailsJson` | `Details` (`ApiKeyAuditRecord.cs:9`) | Today this is a **plain string**, not JSON; either store as-is in `DetailsJson` or wrap as a small JSON object. |
|
||||
|
||||
---
|
||||
|
||||
## Adoption plan → `ZB.MOM.WW.Audit`
|
||||
|
||||
**Effort: LOW.** The seam is tiny (one interface, two methods, one record pair) and the data already
|
||||
maps cleanly onto `AuditEvent`. Concretely:
|
||||
|
||||
1. **Adapter, not rewrite.** Map `IApiKeyAuditStore` → the shared `IAuditWriter`, and
|
||||
`ApiKeyAuditEntry`/`ApiKeyAuditRecord` → `AuditEvent`, using the table above: generate a new
|
||||
`EventId` Guid per write; `KeyId → Actor` (with a `"system"` fallback for null); `EventType → Action`;
|
||||
`CreatedUtc → OccurredAtUtc`; `RemoteAddress → SourceNode`; `constraint-denied → Outcome.Denied`,
|
||||
else `Success`; constant `Category = "ApiKey"`; `Details → DetailsJson`. The three producers
|
||||
(`ApiKeyAdminCliRunner`, `DashboardApiKeyManagementService`, `ConstraintEnforcer`) keep their call
|
||||
sites — only the injected type changes.
|
||||
2. **Redaction stays by-construction.** No separate redactor needs porting; just preserve the rule that
|
||||
callers never put secrets in `DetailsJson` (mirrors `CLAUDE.md:79`). The shared writer can keep its
|
||||
own redaction policy as a defence-in-depth layer.
|
||||
3. **Read-back is free to drop or defer.** `ListRecentAsync` has no production consumer, so the adapter
|
||||
need not implement a shared query API on day one — only the test/CLI read paths exercise it.
|
||||
4. **No new dimensions required.** `CorrelationId` and a structured `Target` are absent today and are
|
||||
*not* in scope to add as part of adoption (descriptive parity only); the canonical record simply
|
||||
leaves them `null`.
|
||||
|
||||
**Coordination risk — sequence against the health/observability work.** A parallel session is actively
|
||||
editing **this same repo** (`mxaccessgw`) for the MEL → Serilog logging migration
|
||||
(`ZB.MOM.WW.Health` + `ZB.MOM.WW.Telemetry` normalization). Because audit adoption here also touches the
|
||||
gateway's `Security/Authentication/` wiring (DI registration in `AuthStoreServiceCollectionExtensions.cs`,
|
||||
and the three producer call sites), the two efforts can collide on the same files and on logging-pipeline
|
||||
DI. **Do not start MxGateway audit adoption until the Serilog migration in this repo has landed (or is
|
||||
explicitly fenced off)**, and confirm with the orchestrator that the logging session is not mid-flight in
|
||||
`Security/` before opening a PR. The audit and logging seams are conceptually independent (audit = durable
|
||||
SQLite record of who-did-what; logging = operational telemetry), but they share the gateway's startup/DI
|
||||
surface, so they must be merged in a defined order rather than in parallel.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Audit — current state: OtOpcUa
|
||||
|
||||
Repo: `~/Desktop/OtOpcUa` (Gitea `lmxopcua`). Stack: .NET 10, Akka.NET cluster, EF Core + SQL Server.
|
||||
All paths below are relative to the repo root. Verified against source on 2026-06-01.
|
||||
|
||||
OtOpcUa already has a structured, idempotent audit pipeline: a cluster-broadcast `AuditEvent`
|
||||
message, a cluster-singleton writer actor that batches and bulk-inserts, and an append-only
|
||||
`ConfigAuditLog` EF entity with two-layer dedup. There is **also** a second, older write path —
|
||||
SQL stored procedures that `INSERT dbo.ConfigAuditLog` directly — so the table has two
|
||||
producers with slightly different column conventions (see §1).
|
||||
|
||||
## 1. How it works today
|
||||
|
||||
**Record shape** — `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs:9-17`:
|
||||
a sealed record `AuditEvent(Guid EventId, string Category, string Action, string Actor,
|
||||
DateTime OccurredAtUtc, string? DetailsJson, NodeId SourceNode, CorrelationId CorrelationId)`.
|
||||
`NodeId` and `CorrelationId` are Commons value-types — `NodeId` wraps a string (the *logical
|
||||
cluster node / host name*, explicitly **not** an OPC UA NodeId per its XML doc,
|
||||
`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/NodeId.cs:3-8`); `CorrelationId` wraps a `Guid`
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/CorrelationId.cs:3`).
|
||||
|
||||
**Transport** — `AuditEvent` is an Akka message meant to be sent to the `AuditWriterActor`
|
||||
**cluster singleton** (`AuditEvent.cs:6` describes it as "cluster-broadcast … consumed by the
|
||||
`AuditWriterActor` singleton"). The singleton is registered through Akka.Hosting at
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs:68-75`
|
||||
(`WithSingleton<AuditWriterActorKey>(AuditWriterSingletonName, …)`). Any cluster member can
|
||||
emit an `AuditEvent`; the singleton is the one sink that persists it.
|
||||
|
||||
**Storage** — EF entity `ConfigAuditLog`
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs:7-44`): append-only
|
||||
("Grants revoked for UPDATE/DELETE on all principals", `ConfigAuditLog.cs:4-5`). Columns:
|
||||
`AuditId` (identity PK), `Timestamp` (default `SYSUTCDATETIME()`), `Principal`, `EventType`,
|
||||
`ClusterId?`, `NodeId?`, `GenerationId?`, `DetailsJson?`, `EventId?` (Guid), `CorrelationId?`
|
||||
(Guid). Mapping/constraints in `OtOpcUaConfigDbContext.cs:429-463`: `DetailsJson` must be valid
|
||||
JSON (`CK_ConfigAuditLog_DetailsJson_IsJson`, line 435-436); `Principal`/`EventType`/`ClusterId`/`NodeId`
|
||||
length-capped (lines 441-444); supporting indexes `IX_ConfigAuditLog_Cluster_Time` (line 449-451)
|
||||
and `IX_ConfigAuditLog_Generation` (line 452-454).
|
||||
|
||||
**Writer / batching** — `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`:
|
||||
a `ReceiveActor` with `FlushBatchSize = 500` (line 25) and `FlushInterval = 5s` (line 26).
|
||||
It buffers events in a `Dictionary<Guid, AuditEvent>` keyed by `EventId` (line 30), flushing
|
||||
when the buffer hits 500 (line 60), when the 5s periodic timer fires (`PreStart`, line 50-53),
|
||||
or on `PreRestart`/`PostStop` (lines 96-107) so a supervisor swap or coordinated shutdown does
|
||||
not lose the buffer. `FlushBuffer` (lines 63-93) snapshots and clears the buffer, then for each
|
||||
event constructs a `ConfigAuditLog` row (lines 75-84): `Timestamp = OccurredAtUtc`,
|
||||
`Principal = Actor`, `EventType = $"{Category}:{Action}"`, `NodeId = SourceNode.Value`,
|
||||
`DetailsJson`, `EventId`, `CorrelationId = CorrelationId.Value`. A failed flush is logged and the
|
||||
batch is **dropped** (`catch` at lines 89-92) — best-effort, no retry/dead-letter.
|
||||
|
||||
**Dedup / idempotency (two layers)** — described at `AuditWriterActor.cs:17-21`:
|
||||
1. *In-buffer* — duplicate `EventId`s within a batch collapse via the dictionary (last-write-wins;
|
||||
`HandleEvent`, lines 55-61).
|
||||
2. *Database* — a **filtered unique index** `UX_ConfigAuditLog_EventId` (`OtOpcUaConfigDbContext.cs:459-462`,
|
||||
`IsUnique()` + `HasFilter("[EventId] IS NOT NULL")`) gives cross-restart safety: a retry of an
|
||||
already-flushed batch hits the constraint, the duplicate insert is dropped, and the rest of the
|
||||
batch survives. `EventId`/`CorrelationId` are nullable so legacy/backfill rows (NULL) don't
|
||||
collide — confirmed in the entity XML (`ConfigAuditLog.cs:33-43`) and migration
|
||||
`Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs:26-31`.
|
||||
|
||||
**Scope** — two producers, two conventions:
|
||||
- **Akka `AuditEvent` path** (the structured one): config writes + authorization checks. The
|
||||
EventType vocabulary lives in the entity XML doc (`ConfigAuditLog.cs:18`): `DraftCreated |
|
||||
DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled |
|
||||
ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt |
|
||||
OpcUaAccessDenied | …`. Note the access-denied / cross-cluster entries are authz-check events,
|
||||
not config writes.
|
||||
- **SQL stored-procedure path** (older, still present): several SPs `INSERT dbo.ConfigAuditLog`
|
||||
directly — e.g. `Published`/`RolledBack`/`NodeApplied`/`ExternalIdReleased`/`CrossClusterNamespaceAttempt`
|
||||
in `Migrations/20260417215224_StoredProcedures.cs:151,217,351,407,504`. These use `SUSER_SNAME()`
|
||||
as `Principal`, set `ClusterId`/`GenerationId`, write a **bare** `EventType` (no `Category:Action`
|
||||
split), and leave `EventId`/`CorrelationId` NULL.
|
||||
|
||||
**Query / UI** — the only read surface is the Admin UI page
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor`
|
||||
(`@page "/clusters/{ClusterId}/audit"`, `[Authorize]`, lines 1-2). It reads the latest
|
||||
`PageSize = 200` rows (line 69) **filtered by `ClusterId`**, newest-first (`OnInitializedAsync`,
|
||||
lines 74-82), and renders Timestamp / Principal / Event(Type) / Node / Correlation(first 8 hex) /
|
||||
Details columns (lines 38-58). Tested in
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs`: count-threshold
|
||||
flush (lines 26-41), in-buffer dedup of duplicate EventIds (lines 45-62), `PostStop` flush
|
||||
(lines 66-81), and the column mapping incl. `EventType == "Config:Edit"` and `NodeId == "node-a"`
|
||||
(lines 85-104).
|
||||
|
||||
> Load-bearing gotcha: the actor path **never sets `ClusterId`** (lines 75-84), but the UI filters
|
||||
> on `ClusterId` (`ClusterAudit.razor:78`). So today the cluster-scoped view surfaces the
|
||||
> stored-procedure rows; structured `AuditEvent` rows written by the actor (which carry the host in
|
||||
> `NodeId`, not `ClusterId`) won't appear under a cluster. Worth flagging during normalization.
|
||||
|
||||
## 2. Mapping to the canonical `AuditEvent`
|
||||
|
||||
Target = `ZB.MOM.WW.Audit.AuditEvent` (built in parallel). OtOpcUa's existing `AuditEvent` is
|
||||
already almost field-for-field aligned; the only synthesized field is `Outcome`.
|
||||
|
||||
| Canonical field | OtOpcUa source | Mapping |
|
||||
|---|---|---|
|
||||
| `Guid EventId` | `AuditEvent.EventId` | Direct. Already the idempotency key (buffer key + `UX_ConfigAuditLog_EventId`). |
|
||||
| `DateTimeOffset OccurredAtUtc` | `AuditEvent.OccurredAtUtc` (`DateTime`) | Direct; widen `DateTime`(UTC) → `DateTimeOffset`. |
|
||||
| `string Actor` | `AuditEvent.Actor` | Direct (→ `ConfigAuditLog.Principal`). At Auth adoption this becomes the `ZB.MOM.WW.Auth` principal. |
|
||||
| `string Action` | `AuditEvent.Action` (+ `Category`) | Direct. Today persisted as `"{Category}:{Action}"` in `EventType`; canonical keeps `Action` and `Category` separate. |
|
||||
| `AuditOutcome Outcome` | *(none)* | **Derived** from the EventType vocabulary, not stored today. `OpcUaAccessDenied`/`CrossClusterNamespaceAttempt` → `Denied`; the config-write verbs → `Success`. No explicit `Failure` value exists yet (a failed flush is dropped, not recorded as an event). |
|
||||
| `string? Category` | `AuditEvent.Category` | Direct (e.g. `"Config"`). |
|
||||
| `string? Target` | *(none)* | No dedicated field today; the closest is `SourceNode`→`NodeId` (the acting host) or details. Leave null or carry the affected object in `DetailsJson`. |
|
||||
| `string? SourceNode` | `AuditEvent.SourceNode` (`NodeId.Value`) | Direct — the logical cluster node / host name (NOT an OPC UA NodeId). Currently lands in `ConfigAuditLog.NodeId`. |
|
||||
| `Guid? CorrelationId` | `AuditEvent.CorrelationId` (`CorrelationId.Value`) | Direct. |
|
||||
| `string? DetailsJson` | `AuditEvent.DetailsJson` | Direct; carries everything else (incl. `ClusterId`/`GenerationId`, which today are separate columns on the SP path). |
|
||||
|
||||
## 3. Adoption plan → `ZB.MOM.WW.Audit`
|
||||
|
||||
**Effort: medium.** OtOpcUa is the *donor* design for the canonical record, so most of the work is
|
||||
re-pointing types and bridging two persistence conventions, not redesigning the pipeline.
|
||||
|
||||
**Replace with the shared library:**
|
||||
- `Commons/Messages/Audit/AuditEvent.cs` → the canonical `ZB.MOM.WW.Audit.AuditEvent`. Add the new
|
||||
`Outcome` field (derive it at every emit site from the EventType vocabulary, e.g.
|
||||
`OpcUaAccessDenied → Denied`); keep `Category`/`Action`/`SourceNode`/`CorrelationId` as-is. Decide
|
||||
whether `SourceNode`/`CorrelationId` carry the Commons value-types or the canonical primitives at
|
||||
the seam (likely a thin adapter at construction).
|
||||
- `AuditWriterActor` → implement the library's `IAuditWriter` (keep the actor as OtOpcUa's
|
||||
Akka-cluster-singleton transport/batching adapter behind that seam; the 500/5s batching,
|
||||
PreRestart/PostStop flush, and two-layer dedup stay bespoke per §"left per-project").
|
||||
|
||||
**Keep bespoke (thin adapter only):**
|
||||
- Transport — the cluster-broadcast → singleton `AuditWriterActor`, batching, and flush triggers.
|
||||
- Storage — the `ConfigAuditLog` EF entity, indexes, and `UX_ConfigAuditLog_EventId` idempotency
|
||||
index. Map the canonical record onto the existing columns; add an `Outcome` column (or fold it into
|
||||
`EventType`/`DetailsJson` if a schema change is undesirable). `ClusterId`/`GenerationId` remain
|
||||
OtOpcUa-specific columns fed via `DetailsJson` or kept as side columns.
|
||||
- Domain vocabulary — the EventType strings (`DraftCreated`, `Published`, `OpcUaAccessDenied`, …)
|
||||
and the `Category:Action` composition convention.
|
||||
- Query/UI — `ClusterAudit.razor` and its `ClusterId` filter.
|
||||
|
||||
**Reconcile, not extract:**
|
||||
- The **two producers** (Akka `AuditEvent` path vs. SQL stored-procedure `INSERT`s using
|
||||
`SUSER_SNAME()`). The SP path bypasses the canonical record entirely and writes a different
|
||||
column convention (bare `EventType`, NULL `EventId`/`CorrelationId`, populated
|
||||
`ClusterId`/`GenerationId`). Adopting the library does not by itself unify these; either route the
|
||||
SP events through the actor or accept that SP rows stay non-idempotent and absent from the
|
||||
`EventId` dedup guarantee. Flag for the normalization spec.
|
||||
- The **`ClusterId`-filter / actor-never-sets-`ClusterId`** mismatch noted in §1 — fix when the
|
||||
query surface is normalized so structured `AuditEvent` rows are discoverable by cluster.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Audit — current state: ScadaBridge
|
||||
|
||||
Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET; solution `ZB.MOM.WW.ScadaBridge.slnx`.
|
||||
Audit code centers on the dedicated `ZB.MOM.WW.ScadaBridge.AuditLog` project, with the shared
|
||||
record + seams living in `ZB.MOM.WW.ScadaBridge.Commons`. All paths relative to repo root.
|
||||
Verified 2026-06-01.
|
||||
|
||||
**By far the largest audit implementation in the family** — a full who-did-what pipeline
|
||||
across a site SQLite hot-path and a central MS SQL store, with forwarding, reconciliation,
|
||||
purge, partition maintenance, redaction, CLI export, hash-chain verify (v1 stub), and a Blazor
|
||||
UI. **Key finding: ScadaBridge is already at the target.** It already has an `IAuditWriter`
|
||||
best-effort seam (near-identical to the canonical contract) and an `IAuditPayloadFilter`
|
||||
redaction seam (= the library's `IAuditRedactor`, just renamed). Adoption is *align, don't
|
||||
replace* — mostly naming alignment; the enormous transport/storage/CLI/UI stays bespoke.
|
||||
|
||||
## 1. How it works today
|
||||
|
||||
### The record — `AuditEvent` (~25 fields)
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs:22` — a `sealed record`,
|
||||
append-only, "single source of truth for AuditLog (#23) rows." Far richer than the canonical
|
||||
10-field event. Notable fields:
|
||||
|
||||
- Identity / correlation: `EventId` (idempotency key, `:25`), `CorrelationId` (per-op
|
||||
lifecycle, `:68`), `ExecutionId` (per-run, `:75`), `ParentExecutionId` (spawner link, `:82`).
|
||||
- Classification: `Channel` (`:62`), `Kind` (`:65`), `Status` (`:109`) — the domain enums (below).
|
||||
- Provenance: `SourceSiteId` (`:85`), `SourceNode` (`:94`, stamped from `INodeIdentityProvider`),
|
||||
`SourceInstanceId` (`:97`), `SourceScript` (`:100`), `Actor` (`:103`), `Target` (`:106`).
|
||||
- Outcome detail: `HttpStatus` (`:112`), `DurationMs` (`:115`), `ErrorMessage` (`:118`),
|
||||
`ErrorDetail` (`:121`).
|
||||
- Payload: `RequestSummary` / `ResponseSummary` (truncated+redacted, `:124`/`:127`),
|
||||
`PayloadTruncated` (`:130`), `Extra` (free-form JSON, `:133`).
|
||||
- Lifecycle plumbing: `IngestedAtUtc` (null on site, stamped at central ingest, `:52`),
|
||||
`ForwardState` (site-only, null on central, `:136`).
|
||||
|
||||
**UTC-forcing init-setters.** `OccurredAtUtc` (`:39`) and `IngestedAtUtc` (`:52`) keep a backing
|
||||
field and call `DateTime.SpecifyKind(value, DateTimeKind.Utc)` on assignment, so a value built
|
||||
from a literal or rehydrated from a SQL Server `datetime2` column (which strips `Kind` on the
|
||||
wire) cannot leak downstream as `Unspecified`/local. The record uses `DateTime` (not
|
||||
`DateTimeOffset`) deliberately, to match the partitioned `datetime2` column shape (`:9-21`).
|
||||
|
||||
### Domain vocabulary — four enums
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/`:
|
||||
|
||||
- `AuditChannel.cs:7` — trust boundary crossed: `ApiOutbound`, `DbOutbound`, `Notification`,
|
||||
`ApiInbound`.
|
||||
- `AuditKind.cs:8` — specific event within a channel: `ApiCall`, `ApiCallCached`, `DbWrite`,
|
||||
`DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`,
|
||||
`CachedSubmit`, `CachedResolve`. Cached variants emit multiple rows per operation.
|
||||
- `AuditStatus.cs:8` — lifecycle status of the row: `Submitted`, `Forwarded`, `Attempted`,
|
||||
`Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`.
|
||||
- `AuditForwardState.cs:9` — site-local forwarding state (central rows leave null): `Pending`,
|
||||
`Forwarded`, `Reconciled`. The site retention purge MUST NOT drop a `Pending` row.
|
||||
|
||||
### The writer seam — `IAuditWriter` (best-effort, never aborts the action)
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IAuditWriter.cs:10` — boundary-side
|
||||
abstraction: `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)` (`:18`). The
|
||||
contract is explicit and matches the canonical seam almost word-for-word: **"Failures must NEVER
|
||||
abort the user-facing action"** (`:8`), best-effort, "implementations must swallow/log internal
|
||||
failures rather than propagating them to the calling boundary code" (`:13-14`).
|
||||
|
||||
### The redaction seam — `IAuditPayloadFilter` (pure, never throws)
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs:22` — `AuditEvent Apply(
|
||||
AuditEvent rawEvent)` (`:30`). Filters an event between construction and persistence:
|
||||
truncates oversized payloads, redacts headers/body/SQL params, sets `PayloadTruncated`.
|
||||
**Pure function** returning a filtered COPY via `with` expressions, and **MUST NOT throw** —
|
||||
on internal failure it over-redacts and increments the `AuditRedactionFailure` health metric
|
||||
(`:11-20`, `:26-28`). This is exactly the canonical `IAuditRedactor` under a different name.
|
||||
Two implementations: `DefaultAuditPayloadFilter.cs:56` (full truncation + header/body/SQL
|
||||
redaction with live options) and `SafeDefaultAuditPayloadFilter.cs:19` (always-safe fallback —
|
||||
header-only redaction, over-redacts on parse failure, `:42-59`).
|
||||
|
||||
### Transport / storage / pipeline — stays per-project
|
||||
|
||||
The `ZB.MOM.WW.ScadaBridge.AuditLog` project is split into `Site/`, `Central/`, `Payload/`, and
|
||||
`Configuration/`. This is the bespoke half and is **not** a candidate for extraction; cited here
|
||||
only to show the scale around the common core:
|
||||
|
||||
- **Site hot-path:** `Site/SqliteAuditWriter.cs:32` (`IAuditWriter` over an owned `SqliteConnection`
|
||||
fed by a bounded `Channel<T>` drained on a background task, so script-thread callers never block
|
||||
on disk I/O; first-write-wins on duplicate `EventId`). `Site/FallbackAuditWriter.cs:28` composes
|
||||
the SQLite writer with a drop-oldest `RingBufferFallback` so a primary failure never bubbles out.
|
||||
`Site/Telemetry/` forwards rows to central over Akka `ClusterClient`.
|
||||
- **Central ingest/store:** `Central/CentralAuditWriter.cs:40` (`ICentralAuditWriter`, direct MS SQL
|
||||
write for central-originated events, per-call EF scope, idempotent `InsertIfNotExistsAsync`,
|
||||
swallows every exception per "alog.md §13"). `Central/AuditLogIngestActor.cs:46` batches site
|
||||
telemetry; `Central/SiteAuditReconciliationActor.cs:68` periodically pulls to catch dropped
|
||||
forwards; `Central/AuditLogPurgeActor.cs:58` enforces retention; `Central/AuditLogPartitionMaintenanceService.cs:55`
|
||||
manages the partitioned table.
|
||||
- **CLI:** `CLI/Commands/AuditCommands.cs:12` builds `export` (`:137`, formats `csv`/`jsonl`/`parquet`)
|
||||
and `verify-chain` (`:226`). Hash-chain verify is currently a **v1 no-op stub** —
|
||||
`CLI/Commands/AuditVerifyChainHelpers.cs:6-10` ("v1 is a no-op").
|
||||
- **UI:** Blazor pages under `CentralUI/Components/Pages/Audit/` (e.g. `AuditLogPage.razor:1`,
|
||||
gated by `[Authorize(Policy = AuthorizationPolicies.OperationalAudit)]`) plus drill-down
|
||||
components in `CentralUI/Components/Audit/`.
|
||||
- **Wiring:** `AuditLog/ServiceCollectionExtensions.cs:59` `AddAuditLog(...)`, `:316`
|
||||
`AddAuditLogCentralMaintenance(...)`.
|
||||
|
||||
## 2. Mapping to the canonical record
|
||||
|
||||
Target (`ZB.MOM.WW.Audit`, being built): `record AuditEvent { Guid EventId; DateTimeOffset
|
||||
OccurredAtUtc; string Actor; string Action; AuditOutcome Outcome; string? Category; string?
|
||||
Target; string? SourceNode; Guid? CorrelationId; string? DetailsJson; }`. ScadaBridge's record is
|
||||
a strict superset — the canonical fields map directly; the rich extras collapse into `DetailsJson`.
|
||||
|
||||
| Canonical field | ScadaBridge source | Notes |
|
||||
|---|---|---|
|
||||
| `EventId` (Guid) | `AuditEvent.EventId` | Direct; same idempotency-key role. |
|
||||
| `OccurredAtUtc` (DateTimeOffset) | `AuditEvent.OccurredAtUtc` (`DateTime`, UTC-forced) | Type bridge `DateTime`(Utc)↔`DateTimeOffset`; semantics identical. |
|
||||
| `Actor` (string) | `AuditEvent.Actor` (nullable) | Direct; ScadaBridge allows null (system-originated rows). |
|
||||
| `Action` (string) | `AuditEvent.Kind` (+`Channel`) | Derive a stable action string, e.g. `{Channel}.{Kind}` (`ApiOutbound.ApiCall`). |
|
||||
| `Outcome` (Success/Failure/Denied) | `AuditEvent.Status` | `Delivered`→Success; `Failed`/`Parked`/`Discarded`→Failure; `InboundAuthFailure`(Kind)→Denied; in-flight `Submitted`/`Forwarded`/`Attempted` collapse to the last-known terminal state when projecting. |
|
||||
| `Category` (string?) | `AuditEvent.Channel` | The coarse bucket; pairs with `Action` above. |
|
||||
| `Target` (string?) | `AuditEvent.Target` | Direct. |
|
||||
| `SourceNode` (string?) | `AuditEvent.SourceNode` | Direct (`node-a`/`central-b`/…). |
|
||||
| `CorrelationId` (Guid?) | `AuditEvent.CorrelationId` | Direct (per-op lifecycle id). |
|
||||
| `DetailsJson` (string?) | `ExecutionId`, `ParentExecutionId`, `SourceSiteId`, `SourceInstanceId`, `SourceScript`, `HttpStatus`, `DurationMs`, `ErrorMessage`, `ErrorDetail`, `RequestSummary`, `ResponseSummary`, `PayloadTruncated`, `Extra`, `IngestedAtUtc`, `ForwardState` | The ~15 rich/plumbing fields serialize into the canonical `DetailsJson` extension. |
|
||||
|
||||
The canonical record is a lossy *projection* of ScadaBridge's — fine for cross-project
|
||||
reporting, but ScadaBridge keeps its full record as the storage shape (the partitioned SQL
|
||||
schema, forwarding state, and reconciliation all depend on the extra columns).
|
||||
|
||||
## 3. Adoption plan → `ZB.MOM.WW.Audit`
|
||||
|
||||
**Posture: align, don't replace.** ScadaBridge is the reference implementation the shared
|
||||
library is being extracted *from*; it already has both seams. Adoption is mostly renaming and
|
||||
contract-confirmation, with a deliberately small touched surface and a large blast radius if
|
||||
done carelessly. **Priority: LOW. Blast radius: HIGH.**
|
||||
|
||||
**Align (small, naming-level):**
|
||||
- **Rename the redaction seam to match the contract.** `IAuditPayloadFilter` → adopt
|
||||
`ZB.MOM.WW.Audit.IAuditRedactor` (`AuditEvent Apply(AuditEvent)` — identical signature and
|
||||
pure/never-throws contract). Either alias `IAuditPayloadFilter : IAuditRedactor` during
|
||||
transition or rename outright; `DefaultAuditPayloadFilter` / `SafeDefaultAuditPayloadFilter`
|
||||
implement it unchanged. See [`../../shared-contract/`](../../shared-contract/).
|
||||
- **Confirm the writer contract matches.** `IAuditWriter.WriteAsync(AuditEvent, CancellationToken
|
||||
= default)` is already byte-for-byte the canonical signature, and the "never abort the
|
||||
user-facing action" wording matches. The only delta is the **record type**: the library's
|
||||
`IAuditWriter` is typed on the *canonical* 10-field `AuditEvent`, while ScadaBridge's is typed on
|
||||
its ~25-field record. Resolve by either (a) keeping ScadaBridge's writer on its own rich record
|
||||
and adopting only the library's *interface name + outcome enum*, or (b) having the shared seam be
|
||||
generic over the event type. **Recommended: (a)** — adopt the canonical `AuditOutcome` enum and
|
||||
the interface naming, but keep the bespoke `AuditEvent` as ScadaBridge's storage record, since the
|
||||
whole transport/partition/forwarding layer is built on its extra columns. (Best-practice fit: this
|
||||
is the minimal-coupling option — share the contract, not the schema.)
|
||||
|
||||
**Keep bespoke (the large, untouched majority):**
|
||||
- The entire `Site/` (SQLite hot-path + ring-buffer fallback + telemetry forwarder) and `Central/`
|
||||
(ingest / reconcile / purge / partition maintenance) pipeline.
|
||||
- The `AuditEvent` rich record itself, the four domain enums (`AuditChannel`/`AuditKind`/
|
||||
`AuditStatus`/`AuditForwardState`), CLI `export`/`verify-chain`, and the Blazor audit UI.
|
||||
- The redaction *policy* (`DefaultAuditPayloadFilter` options, per-target overrides) — only the
|
||||
interface name is shared, not the implementation.
|
||||
|
||||
**Net:** ScadaBridge converges by renaming one interface and adopting the canonical `AuditOutcome`
|
||||
enum + the `Kind`/`Channel`→`Action`/`Category` and `…`→`DetailsJson` projection for any
|
||||
cross-project reporting. No transport, storage, CLI, or UI is replaced. Sequencing and the
|
||||
cross-project gap list live in [`../../GAPS.md`](../../GAPS.md); the canonical target is
|
||||
[`../../spec/SPEC.md`](../../spec/SPEC.md).
|
||||
@@ -0,0 +1,153 @@
|
||||
# Proposed shared library: `ZB.MOM.WW.Audit`
|
||||
|
||||
A contract on paper — the public surface to extract so the three projects stop
|
||||
re-implementing audit-event capture with incompatible shapes. Realizes
|
||||
[`../spec/SPEC.md`](../spec/SPEC.md).
|
||||
**Not yet created.** Reference implementations already exist: ScadaBridge's
|
||||
`IAuditWriter`/`IAuditPayloadFilter` (already at target shape), mxaccessgw
|
||||
structured-log audit trail, OtOpcUa admin-UI audit log.
|
||||
|
||||
## Package (.NET 10)
|
||||
|
||||
```
|
||||
ZB.MOM.WW.Audit # the single package: event record, seams, helpers, DI wiring
|
||||
```
|
||||
|
||||
Single package, single DLL. Only non-BCL dependency:
|
||||
`Microsoft.Extensions.DependencyInjection.Abstractions` (for `AddZbAudit`).
|
||||
Published to the Gitea NuGet feed; SemVer.
|
||||
|
||||
| Package (→ DLL) | Transitive deps | OtOpcUa | mxaccessgw | ScadaBridge |
|
||||
|---|---|---|---|---|
|
||||
| `ZB.MOM.WW.Audit` | `Microsoft.Extensions.DependencyInjection.Abstractions` | ✅ | ✅ | ✅ |
|
||||
|
||||
All three auth-bearing processes are .NET 10 — the x86/net48 mxaccessgw worker does
|
||||
no audit emission, so net48 multi-targeting is **not** required.
|
||||
|
||||
## `AuditEvent` record and `AuditOutcome` enum
|
||||
|
||||
```csharp
|
||||
public sealed record AuditEvent {
|
||||
public required Guid EventId { get; init; }
|
||||
public required DateTimeOffset OccurredAtUtc { get; init; } // normalized to UTC on assignment
|
||||
public required string Actor { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required AuditOutcome Outcome { get; init; }
|
||||
public string? Category { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? SourceNode { get; init; }
|
||||
public Guid? CorrelationId { get; init; }
|
||||
public string? DetailsJson { get; init; }
|
||||
}
|
||||
|
||||
public enum AuditOutcome { Success, Failure, Denied }
|
||||
```
|
||||
|
||||
`OccurredAtUtc` is the only field with a normalization contract: any value assigned
|
||||
is coerced to UTC (via `ToUniversalTime()`). All other fields are caller-supplied and
|
||||
carried through without transformation by the library internals.
|
||||
|
||||
## Seams
|
||||
|
||||
### `IAuditWriter`
|
||||
|
||||
```csharp
|
||||
public interface IAuditWriter
|
||||
{
|
||||
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
**Hard contract:**
|
||||
- Best-effort delivery. The implementation **MUST swallow all internal failures** and
|
||||
**MUST NOT throw** to the caller. A write that fails silently is preferable to
|
||||
a write that crashes the calling thread or kills a request pipeline.
|
||||
- `CancellationToken` is respected for cooperative cancellation but a cancellation
|
||||
does not constitute a contract violation; the implementation may choose to complete
|
||||
a partially-written event anyway.
|
||||
|
||||
### `IAuditRedactor`
|
||||
|
||||
```csharp
|
||||
public interface IAuditRedactor
|
||||
{
|
||||
AuditEvent Apply(AuditEvent rawEvent);
|
||||
}
|
||||
```
|
||||
|
||||
**Hard contract:**
|
||||
- Pure function (no I/O, no side effects).
|
||||
- **MUST NOT throw.** On any internal failure the implementation must over-redact
|
||||
(e.g. replace the affected field with a sentinel such as `"[redacted]"`) rather
|
||||
than propagate the exception. Lossier output is always preferable to a thrown
|
||||
exception reaching the caller.
|
||||
|
||||
## Shipped helpers (concrete)
|
||||
|
||||
### Redactors
|
||||
|
||||
| Type | Behaviour |
|
||||
|---|---|
|
||||
| `NullAuditRedactor` | Identity — returns the event unchanged. Registered as the default by `AddZbAudit`. |
|
||||
| `TruncatingAuditRedactor` | Caps `DetailsJson` and `Target` to a configurable maximum length and appends a marker (e.g. `"…"`) when truncated. Never throws. Configured via `TruncatingAuditRedactorOptions`. |
|
||||
| `TruncatingAuditRedactorOptions` | Options record for `TruncatingAuditRedactor`: `MaxDetailsJsonLength`, `MaxTargetLength`, `TruncationMarker`. |
|
||||
|
||||
### Writers
|
||||
|
||||
| Type | Behaviour |
|
||||
|---|---|
|
||||
| `NoOpAuditWriter` | Discards every event. Registered as the default by `AddZbAudit`; consumer replaces with a real writer. |
|
||||
| `CompositeAuditWriter` | Fan-out: forwards each event to an ordered list of inner `IAuditWriter` instances. A failing inner writer is swallowed (per the `IAuditWriter` contract) — it does **not** abort the remaining writers in the list. |
|
||||
| `RedactingAuditWriter` | Decorator: calls `IAuditRedactor.Apply` on the event, then delegates the redacted event to an inner `IAuditWriter`. Separates the redaction concern from any concrete writer. |
|
||||
|
||||
## DI wiring
|
||||
|
||||
```csharp
|
||||
public static IServiceCollection AddZbAudit(this IServiceCollection services);
|
||||
```
|
||||
|
||||
Registers defaults via `TryAdd` so any prior consumer registration wins:
|
||||
|
||||
- `IAuditRedactor` → `NullAuditRedactor` (singleton)
|
||||
- `IAuditWriter` → `NoOpAuditWriter` (singleton)
|
||||
|
||||
A consumer that registers its own `IAuditWriter` (e.g. a Serilog-backed writer or a
|
||||
`CompositeAuditWriter`) before or after calling `AddZbAudit` will see its registration
|
||||
respected. `AddZbAudit` does **not** clear or override existing registrations.
|
||||
|
||||
## Relationship to Telemetry (`ILogRedactor`)
|
||||
|
||||
`IAuditRedactor` mirrors Telemetry.Serilog's `ILogRedactor` in shape and naming — same
|
||||
single-method contract, same "pure, must not throw, over-redact on failure" semantics —
|
||||
so that a future `ZB.MOM.WW.Hosting` aggregator package can wire both behind a single
|
||||
configuration surface without an impedance mismatch.
|
||||
|
||||
`ZB.MOM.WW.Audit` has **no dependency** on `ZB.MOM.WW.Telemetry` or any Serilog package.
|
||||
The alignment is intentional design convergence; the independence is a hard boundary.
|
||||
|
||||
## What stays in each consumer
|
||||
|
||||
OtOpcUa: admin-UI audit sink (Blazor event handler → `IAuditWriter`), `Category`
|
||||
constants specific to OPC UA operations.
|
||||
|
||||
mxaccessgw: gRPC interceptor that captures actor/action from call metadata; constraint-aware
|
||||
`Category` tagging; `DetailsJson` serialization of gateway-specific payloads.
|
||||
|
||||
ScadaBridge: site-scoped `SourceNode` population; `ManagementActor` enforcement callbacks;
|
||||
`IAuditPayloadFilter` → `IAuditRedactor` migration (shape is already equivalent — adoption
|
||||
is a near-zero-effort rename).
|
||||
|
||||
## Open contract questions
|
||||
|
||||
1. **Batching**: a `WriteBatchAsync(IEnumerable<AuditEvent>, CancellationToken)` overload on
|
||||
`IAuditWriter` may be warranted once a database-backed writer is in use. Defer until
|
||||
the first consumer demonstrates the need; batching can be added without breaking the
|
||||
existing single-event surface.
|
||||
2. **Structured `DetailsJson`**: confirm whether callers should supply raw JSON strings or
|
||||
whether a typed `TDetails` generic overload (serialized internally) is cleaner. The
|
||||
current `string?` keeps the library dependency-free but shifts serialization to the caller.
|
||||
3. **`CompositeAuditWriter` error policy**: decide whether per-writer failure should be
|
||||
observable (e.g. an optional `ILogger<CompositeAuditWriter>`) or always silently dropped.
|
||||
Logging the failure is diagnostic-friendly but adds a logging dependency.
|
||||
|
||||
See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,146 @@
|
||||
# Audit — normalized target spec
|
||||
|
||||
Status: **Draft**. The single design the sister projects converge on. Derived from the three
|
||||
code-verified current-state docs (`../current-state/`) and the locked design
|
||||
(`../../../docs/plans/2026-06-01-audit-component-design.md`). Goal is *path to shared code*
|
||||
(`../shared-contract/ZB.MOM.WW.Audit.md`), so each normalized section maps to a shared library seam.
|
||||
|
||||
## 0. Normalized vs left-per-project
|
||||
|
||||
**Normalized here** (the shared `ZB.MOM.WW.Audit` library):
|
||||
|
||||
- **The canonical `AuditEvent` record** — required core (`EventId`, `OccurredAtUtc`, `Actor`,
|
||||
`Action`, `Outcome`) + optional common (`Category`, `Target`, `SourceNode`, `CorrelationId`) +
|
||||
the `DetailsJson` extension bag. The full field-by-field reference is [`EVENT-MODEL.md`](EVENT-MODEL.md).
|
||||
- **`AuditOutcome`** — the 3-value `Success | Failure | Denied` enum (§3). This is a *new*
|
||||
normalized field every app derives; see [`EVENT-MODEL.md`](EVENT-MODEL.md) for the per-app derivation.
|
||||
- **The two seams** — `IAuditWriter` (best-effort, never throws to caller, §1) and `IAuditRedactor`
|
||||
(pure, never throws, over-redacts on failure, §2).
|
||||
|
||||
**Explicitly NOT normalized** (domain-specific / divergent — keep per project):
|
||||
|
||||
- **Transport & storage** — OtOpcUa's Akka cluster-broadcast → singleton `AuditWriterActor` (batch
|
||||
500 / 5 s, two-layer dedup) over `ConfigAuditLog`; MxGateway's SQLite `IApiKeyAuditStore` append +
|
||||
list-recent; ScadaBridge's site-SQLite hot-path → central MS SQL ingest / reconcile / purge /
|
||||
partition-maintenance / hash-chain pipeline. The shared core carries no Akka / EF / SQLite /
|
||||
Serilog dependency; its only non-BCL dependency is `Microsoft.Extensions.DependencyInjection.Abstractions`
|
||||
(for `AddZbAudit`).
|
||||
- **Domain vocabulary** — ScadaBridge's `Channel` / `Kind` / `Status` / `ForwardState` enums and
|
||||
OtOpcUa's `EventType` strings (`DraftCreated`, `Published`, `OpcUaAccessDenied`, …). These map
|
||||
*into* `Action` / `Category` / `Outcome` / `DetailsJson`; they do not leak into the shared type.
|
||||
- **Query / CLI / UI / export** surfaces (OtOpcUa `ClusterAudit.razor`; ScadaBridge `export` /
|
||||
`verify-chain` CLI + Blazor audit pages; MxGateway's unused `ListRecentAsync`).
|
||||
- **Each app's redaction *policy*** — *which* fields/commands/payloads are sensitive. Only the
|
||||
`IAuditRedactor` *seam* is shared; the `Default` / `Safe` filter behaviour stays per-project.
|
||||
|
||||
> **Scope of the producer path.** OtOpcUa has **two producers** writing the same `ConfigAuditLog`
|
||||
> table — the structured Akka `AuditEvent` path *and* older SQL stored procedures that `INSERT`
|
||||
> directly (`SUSER_SNAME()`, bare `EventType`, NULL `EventId`). Normalization targets the
|
||||
> **structured producer path** (the one that builds an `AuditEvent`), not every SQL insert; the SP
|
||||
> path stays per-project and is a reconcile item, not an extraction item (`../GAPS.md`).
|
||||
|
||||
## 1. The writer contract — `IAuditWriter` (best-effort)
|
||||
|
||||
```csharp
|
||||
public interface IAuditWriter
|
||||
{
|
||||
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
Audit is a side-channel, never on the critical path. The hard rule:
|
||||
|
||||
- **`WriteAsync` MUST NOT throw to the caller.** An implementation swallows/logs its own internal
|
||||
failures; a failed write **must never abort the user-facing action** it is recording. (ScadaBridge's
|
||||
seam already states this almost word-for-word: "Failures must NEVER abort the user-facing action.")
|
||||
- Idempotency is carried by `EventId`, so retries and at-least-once transports are safe (OtOpcUa's
|
||||
filtered-unique `EventId` index and ScadaBridge's first-write-wins are both honoured by this key).
|
||||
- Delivery is at-most-once *as a contract* — a writer MAY drop on failure (OtOpcUa drops a failed
|
||||
batch; ScadaBridge's ring-buffer fallback drops oldest). Durability is a per-project transport
|
||||
decision, not part of this seam.
|
||||
|
||||
Shipped helpers (the only concrete writers): `NoOpAuditWriter` (discards — tests / disabled audit),
|
||||
`CompositeAuditWriter` (fans out to N writers; **one writer throwing does not stop the others**), and
|
||||
`RedactingAuditWriter` (decorator: applies the redactor, then delegates to an inner writer).
|
||||
|
||||
## 2. The redactor contract — `IAuditRedactor` (never throws)
|
||||
|
||||
```csharp
|
||||
public interface IAuditRedactor
|
||||
{
|
||||
AuditEvent Apply(AuditEvent rawEvent);
|
||||
}
|
||||
```
|
||||
|
||||
A pure projection from a raw event to a safe one, applied between event construction and the writer
|
||||
chain. The hard rule:
|
||||
|
||||
- **`Apply` MUST NOT throw.** On any internal failure it **over-redacts** (returns a strictly safer
|
||||
event) rather than propagating — a redactor that throws would either crash the audit path or leak
|
||||
the unredacted event. (ScadaBridge's `SafeDefaultAuditPayloadFilter` is the reference: header-only
|
||||
redaction, over-redacts on parse failure.)
|
||||
- It is a **pure function** returning a filtered *copy* (via `with`); it does not mutate the input or
|
||||
perform I/O.
|
||||
|
||||
The seam is **aligned-but-independent** with Telemetry's `ILogRedactor` — same shape and naming
|
||||
discipline so a future `ZB.MOM.WW.Hosting` aggregator wires both with one mental model — but there is
|
||||
**no cross-package dependency**. Shipped helpers: `NullAuditRedactor` (identity — the default when no
|
||||
policy is configured) and `TruncatingAuditRedactor` (caps `DetailsJson` / `Target` to a configured
|
||||
max + sets a truncation marker; never throws). The *secret-field policy* (which fields/commands are
|
||||
sensitive) stays per-project via composition.
|
||||
|
||||
## 3. `AuditOutcome` — the new normalized field
|
||||
|
||||
`Outcome` is in the **required core**, but **no app stores it today** — each encodes outcome
|
||||
implicitly and must **derive** it at adoption (this is the one genuinely new field):
|
||||
|
||||
- **OtOpcUa** — derived from the `EventType` vocabulary (`OpcUaAccessDenied` /
|
||||
`CrossClusterNamespaceAttempt` → `Denied`; config-write verbs → `Success`).
|
||||
- **MxGateway** — `constraint-denied` → `Denied`; key-lifecycle events → `Success`.
|
||||
- **ScadaBridge** — `AuditStatus` → `Outcome` (`Delivered` → `Success`; `Failed` / `Parked` /
|
||||
`Discarded` → `Failure`; `InboundAuthFailure` kind → `Denied`).
|
||||
|
||||
The three values normalize denials and failures across the family without importing any app's full
|
||||
taxonomy. The enum definition and the complete state-by-state mapping live in [`EVENT-MODEL.md`](EVENT-MODEL.md).
|
||||
|
||||
## 4. The hinge — audit closes the loop on Auth
|
||||
|
||||
Every audit row's `Actor` is the *who*, which is exactly the identity the **Auth** component already
|
||||
normalizes (LDAP/GLAuth principal, API-key name). Auth is the read side ("who is this and what may
|
||||
they do"); audit is the write side ("who did what"). The spec ties them by stating:
|
||||
|
||||
- **`Actor` SHOULD be the `ZB.MOM.WW.Auth` principal** at adoption time.
|
||||
- But `Actor` is **kept as a plain `string`** in the contract, so the library carries **no dependency
|
||||
on `ZB.MOM.WW.Auth`**. (MxGateway's keyless events — `init-db` / `list-keys` — supply a `"system"` /
|
||||
`"cli"` fallback rather than leaving the required field empty.)
|
||||
|
||||
This mirrors Auth's own decision to keep audit *read* inside `OBSERVE` and audit *export* inside
|
||||
`ADMINISTER` rather than minting a separate auditor role: the two components share a vocabulary, not a
|
||||
dependency.
|
||||
|
||||
## 5. ScadaBridge is already at the target
|
||||
|
||||
ScadaBridge already ships **both** seams: an `IAuditWriter` whose best-effort contract matches
|
||||
word-for-word, and an `IAuditPayloadFilter` that *is* the canonical `IAuditRedactor` under a different
|
||||
name (identical `AuditEvent Apply(AuditEvent)` signature, pure / never-throws / over-redacts). The
|
||||
library essentially **lifts ScadaBridge's seams**.
|
||||
|
||||
The one real (non-naming) decision is the **writer's record type**: the canonical `IAuditWriter` is
|
||||
typed on the 10-field `AuditEvent`; ScadaBridge's writer is typed on its ~25-field record.
|
||||
|
||||
> **Resolution (recommended):** share the **interface *name* + the `AuditOutcome` enum**, not the
|
||||
> record schema. ScadaBridge keeps its rich ~25-field record as its **storage shape** (its whole
|
||||
> transport / partition / forwarding / reconciliation layer is built on the extra columns), and maps
|
||||
> to the canonical 10-field record **only at cross-app reporting boundaries**. This is the
|
||||
> minimal-coupling option — share the contract, not the schema — and avoids making the shared seam
|
||||
> generic over the event type. ScadaBridge therefore converges by **renaming one interface** and
|
||||
> adopting `AuditOutcome`, with no transport / storage / CLI / UI change.
|
||||
|
||||
## 6. Acceptance (what "converged" means)
|
||||
|
||||
A project is converged when: (a) its structured audit-producer path constructs the canonical
|
||||
`AuditEvent` (with `Outcome` derived per §3) and persists via an implementation of `IAuditWriter`;
|
||||
(b) any redaction runs through an `IAuditRedactor`; (c) `Actor` carries the `ZB.MOM.WW.Auth` principal
|
||||
where one exists (string fallback otherwise); with its transport, storage, domain vocabulary, query
|
||||
surfaces, and redaction *policy* unchanged. Per-project deltas and the adoption backlog are in
|
||||
[`../GAPS.md`](../GAPS.md); the proposed library API is [`../shared-contract/ZB.MOM.WW.Audit.md`](../shared-contract/ZB.MOM.WW.Audit.md).
|
||||
+8
-2
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user