Compare commits

...

33 Commits

Author SHA1 Message Date
Joseph Doherty 4b90ebb588 docs: reflect final delivery — Auth+Audit normalization merged to each repo's LOCAL default (main/master) 2026-06-03, NOT pushed (origin untouched), feat/* branches kept 2026-06-03 00:31:07 -04:00
Joseph Doherty 4de61d29f5 docs: PROGRAM COMPLETE — Auth+Audit normalization adopted across all 3 repos (Phases 0-3); mark exit-gate (CLAUDE.md Auth/Audit rows + components/{auth,audit}/GAPS.md adopted, local-only/not-pushed); tasks #10/#30/#31 done 2026-06-02 15:42:23 -04:00
Joseph Doherty 1ec057a32a plan: Task 2.5 (ScadaBridge audit full re-arch C1-C7) DONE+reviewed -> PHASE 2 COMPLETE (audit adopted across all 3 repos, deep/canonical, local-only). Next = Phase 3 Actor->principal wiring 2026-06-02 15:10:54 -04:00
Joseph Doherty a591a9fb47 plan(2.5): ScadaBridge audit C5 done+reviewed (central migration, MSSQL-verified); C6 subsumed (consumer surfaces already canonical via C3 shims); C7 (perf re-baseline + cleanup) in progress 2026-06-02 14:24:32 -04:00
Joseph Doherty e9100d0b74 plan(2.5): ScadaBridge audit C4 done+reviewed (site sidecar); C5 (central migration) in progress 2026-06-02 13:34:12 -04:00
Joseph Doherty 672ac5ff04 plan(2.5): ScadaBridge audit C3 done+reviewed (record swap keystone); C4 (site sidecar) in progress 2026-06-02 13:07:32 -04:00
Joseph Doherty f073241f52 plan(2.5): ScadaBridge audit re-arch C1+C2 done (reviewed); C3 (atomic record swap) in progress 2026-06-02 11:54:57 -04:00
Joseph Doherty 98e957903f plan(2.5): ScadaBridge audit full-rearch design + C1-C7 decomposition (sidecar forwarding, new-table-copy central migration, persisted computed cols, canonical record everywhere) 2026-06-02 10:36:00 -04:00
Joseph Doherty ca2a9ac507 plan(phase2): OtOpcUa 2.1/2.2 + MxGateway 2.3 DONE (deep audit adoption, spec+code reviewed, local-only); ScadaBridge 2.5 pending variant decision 2026-06-02 10:26:55 -04:00
Joseph Doherty abe06a2163 plan(phase2): Task 2.0 gate DONE — verified plan specs materially off (MxGw store moved to lib, OtOpcUa path dormant, SB rename structurally impossible); user chose DEEP adopt + pause; corrected deep design in -phase2-deep.md; PAUSED for review 2026-06-02 09:13:09 -04:00
Joseph Doherty 95681ac0b2 plan(phase1): Tasks 1.5/1.6/1.7 done+reviewed — PHASE 1 COMPLETE across all 3 repos (claims/cookies, dev base DN dc=zb, canonical-six roles + SB SoD collapse + config-DB migrations); next = Phase 2 audit 2026-06-02 08:15:46 -04:00
Joseph Doherty d73762bf76 plan(phase1): ScadaBridge re-arch C5 done+reviewed; Task 1.3 (ApiKeys adopt) COMPLETE across all 3 repos; installer/secret catch noted 2026-06-02 05:51:10 -04:00
Joseph Doherty 02a84b074a plan(phase1): ScadaBridge re-arch C4 done+reviewed (TransportExport excludes keys); C5 (retire entity) next 2026-06-02 05:17:09 -04:00
Joseph Doherty 9b5535ea47 plan(phase1): ScadaBridge re-arch C3 done+reviewed (CentralUI onto seam); C4 next 2026-06-02 04:50:09 -04:00
Joseph Doherty 406ede19dd plan(phase1): ScadaBridge re-arch C2 done+reviewed (mgmt+CLI onto seam); C3 next 2026-06-02 04:25:02 -04:00
Joseph Doherty ba7b38a654 plan(phase1): ScadaBridge re-arch C1 done+reviewed; 2 pre-existing Host.Tests baseline reds fixed; C2 next 2026-06-02 04:03:31 -04:00
Joseph Doherty e69e9c635b plan(phase1): ScadaBridge re-arch discovered architecture (CentralUI direct-repo + TransportExport) + C1-C5 decomposition + transport=exclude-keys 2026-06-02 03:22:19 -04:00
Joseph Doherty a4f9968917 plan(phase1): Auth lib 0.1.3 published (SetScopes/SetEnabled); ScadaBridge re-arch C mapping 2026-06-02 03:14:29 -04:00
Joseph Doherty 290e85cb38 test(auth.apikeys): store-level arg guards + SetEnabledAsync idempotence (review M1/M2) 2026-06-02 03:12:24 -04:00
Joseph Doherty 468959ca8a feat(auth.apikeys): add IApiKeyAdminStore.SetScopesAsync + SetEnabledAsync (editable scopes + reversible enable, no schema change); bump 0.1.3 2026-06-02 03:08:19 -04:00
Joseph Doherty 30c60f9d5f plan(phase1): SB ApiKeys A+B foundation done+reviewed; C/D/E pending 2026-06-02 02:50:57 -04:00
Joseph Doherty d30cdea487 plan(phase1): ScadaBridge ApiKeys full-adopt re-arch spec + sub-task decomposition 2026-06-02 02:29:03 -04:00
Joseph Doherty f2b73367d5 plan(phase1): MxGateway 1.3 done+approved (lib 0.1.2); ScadaBridge 1.3 pending 2026-06-02 02:14:45 -04:00
Joseph Doherty da669bfc9b fix(auth.apikeys): stamp schema version 2 to match donor gateway DBs; bump 0.1.2
The store was extracted from MxAccessGateway, whose deployed gateway-auth.db
is at schema_version=2. The library capped at 1 and threw on a newer on-disk
version -> gateway would fail to boot. Final schema is byte-identical since v1;
stamp 2 so existing deployed DBs interoperate (no key re-issuance). +2 tests.
2026-06-02 01:45:57 -04:00
Joseph Doherty 2d50d5dcf0 plan(phase1): 1.2/1.4 done across 3 repos (lib 0.1.1); remaining 1.3/1.5-1.7 2026-06-02 01:38:50 -04:00
Joseph Doherty aecc106657 fix(auth.ldap): skip LdapOptionsValidator when Enabled=false; bump 0.1.1
A disabled LDAP provider's connection fields are inert — don't require
Server/SearchBase/ServiceAccountDn at startup when Enabled=false. Surfaced
by the MxGateway 1.2 review (dashboard LDAP can be disabled). +1 test.
2026-06-02 01:17:53 -04:00
Joseph Doherty 0586e64f64 plan(phase1): record Task 1.2 review findings + LdapOptionsValidator 0.1.1 question 2026-06-02 01:12:20 -04:00
Joseph Doherty 37c03e5fc2 plan(phase1): note Roles sub-namespace; Task 1.1 done+approved (3 repos) 2026-06-02 00:34:13 -04:00
Joseph Doherty bea08f9673 plan(phase1): lock resolved decisions (SB ApiKeys full adopt, roles, dev hatches) 2026-06-02 00:25:53 -04:00
Joseph Doherty 32fd953969 plan(phase1): Task 1.0 exploration findings + elaborated Auth cutover
Per-app cutover steps mapped to the library surface; flags 5 findings that
change the plan (OtOpcUa section is Security:Ldap not Authentication:Ldap;
singleton 'bug' already mitigated; ScadaBridge inbound API keys are a
re-architecture not a reformat; OtOpcUa config+DB mapping + DevStubMode +
2nd LDAP consumer; MxGateway ApiKeys is the low-risk donor path).
2026-06-02 00:24:03 -04:00
Joseph Doherty c715565bd2 build(audit): add Gitea push.sh mirroring Auth's 2026-06-02 00:13:24 -04:00
Joseph Doherty f98fa84e4a plan: implementation plan + task graph for Auth+Audit normalization
Phase 0 command-exact (publish + feed-map); Phases 1-3 decomposed into
bite-sized cutover tasks with files-to-edit contracts, classification,
parallelizability, and per-phase explore/elaborate gates. Co-located
.tasks.json mirrors native tasks #7-#31.
2026-06-02 00:11:48 -04:00
Joseph Doherty 6ec1ea7d65 docs: design for full Auth+Audit normalization across 3 sister projects
Approved brainstorming output: two-library program (publish + adopt
ZB.MOM.WW.Auth then ZB.MOM.WW.Audit across OtOpcUa, MxAccessGateway,
ScadaBridge), library-major waterfall, ending with audit Actor wired
from the Auth principal. Local-only delivery; verified feed/source state.
2026-06-02 00:04:33 -04:00
22 changed files with 1706 additions and 18 deletions
+23 -6
View File
@@ -120,12 +120,12 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Auth (login / identity / authz) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| Auth (login / identity / authz) | Adopted (lib `0.1.3`; all 3 apps, merged to **local default** main/master, **not pushed**) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| 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/) |
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
| 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/) |
| Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master, **not pushed**) | 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
@@ -137,7 +137,14 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Auth/`](ZB
(its own nested git repo; .NET 10; 4 packages — `Abstractions`, `Ldap`, `ApiKeys`, `AspNetCore`;
172 tests; `dotnet pack` → 4 nupkgs @ 0.1.0). The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md).
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/auth/GAPS.md`](components/auth/GAPS.md) (#8).
**Adopted across all three apps on 2026-06-02** (auth GAPS #1#8) on each repo's `feat/adopt-zb-auth` branch —
committed + reviewed, then **fast-forward-merged into the repo's local default (main/master) on 2026-06-03; NOT pushed**
(origin untouched; the `feat/*` branches were kept as history). Cutover: shared `Auth.Ldap`,
`Auth.ApiKeys` (ScadaBridge inbound fully re-architected to the keyId/Bearer model), `IGroupRoleMapper<TRole>` seam,
`Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the
canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: OtOpcUa `0.1.1`,
MxGateway `0.1.2`, ScadaBridge `0.1.3`. Per-repo detail in [`components/auth/GAPS.md`](components/auth/GAPS.md) +
`docs/plans/2026-06-02-auth-audit-normalization*.md`.
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
@@ -231,10 +238,20 @@ principal. `IAuditRedactor` is aligned with Telemetry's `ILogRedactor` seam conv
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).
**Adopted across all three apps on 2026-06-02** (audit GAPS #1#6) on each repo's `feat/adopt-zb-audit` branch
(stacked on `feat/adopt-zb-auth`) — committed + reviewed, then **merged into the repo's local default (main/master) on
2026-06-03; NOT pushed** (origin untouched). Depth =
**DEEP adopt** (the canonical 9-field `AuditEvent` is the record everywhere; domain fields ride in `DetailsJson`).
OtOpcUa: canonical record + `AuditWriterActor : IAuditWriter` + `Outcome` column/migration + `ClusterAudit` fix.
MxGateway: new canonical SQLite `audit_event` store + `IAuditWriter` + `IApiKeyAuditStore`→canonical adapter.
**ScadaBridge: a full audit-subsystem re-architecture** (the program's largest task) — canonical record everywhere via a
deterministic codec; site SQLite split into `audit_event` + an `audit_forward_state` forwarding sidecar; central
partitioned `dbo.AuditLog` collapsed to 10 canonical cols + persisted computed cols (`CollapseAuditLogToCanonical`
migration, MSSQL-verified). Phase 3 wires `Actor` from the Auth principal at authenticated emit sites (per-app
`IAuditActorAccessor`). Per-repo detail in [`components/audit/GAPS.md`](components/audit/GAPS.md) +
`docs/plans/2026-06-02-auth-audit-normalization-phase2-deep.md` + `…-scadabridge-audit-rearch.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).
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge — DEEP-adopted as the canonical record).
## Per-project primary commands
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# push.sh — pack and push the ZB.MOM.WW.Audit NuGet package to the Gitea feed.
#
# Required environment variables:
# GITEA_NUGET_SOURCE — full URL of the Gitea NuGet feed
# e.g. https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json
# GITEA_NUGET_KEY — Gitea access token with package:write permission
#
# Usage:
# export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
# export GITEA_NUGET_KEY="your-gitea-token"
# ./build/push.sh
set -euo pipefail
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
dotnet pack -c Release -o ./artifacts
dotnet nuget push "./artifacts/*.nupkg" \
--source "$GITEA_NUGET_SOURCE" \
--api-key "$GITEA_NUGET_KEY" \
--skip-duplicate
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<Version>0.1.3</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
@@ -55,6 +55,12 @@ public interface IApiKeyAdminStore
Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct);
Task<bool> DeleteAsync(string keyId, CancellationToken ct);
/// <summary>Replaces the scope set on an existing key. Does not touch the secret. Returns false if the key does not exist.</summary>
Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct);
/// <summary>Enables (clears revoked_utc) or disables (sets revoked_utc) a key WITHOUT changing its secret. Returns false if the key does not exist.</summary>
Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct);
/// <summary>
/// Enumerates all API keys as hash-free <see cref="ApiKeyListItem"/> projections, newest first.
/// The secret hash is never selected, so callers cannot use this to recover secret material.
@@ -187,6 +187,53 @@ public sealed class ApiKeyAdminCommands
return new KeyActionResult(deleted, status);
}
/// <summary>
/// set-scopes: replaces the scope set on an existing key WITHOUT touching its secret, and
/// appends a <c>set-scopes</c> audit entry. Only the scope count is recorded in the audit
/// details — the scope values themselves are not logged verbatim.
/// All attempts are audited, including failures (key not found) — this is intentional to
/// maintain a complete security trail.
/// </summary>
public async Task<KeyActionResult> SetScopesAsync(
string keyId, IReadOnlySet<string> scopes, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentNullException.ThrowIfNull(scopes);
bool updated = await _adminStore.SetScopesAsync(keyId, scopes, ct).ConfigureAwait(false);
string status = updated ? "scopes-set" : "not-found";
// Record only the count, never the scope contents, to avoid leaking authority detail into audit.
await AppendAuditAsync(keyId, "set-scopes", remoteAddress, $"{status}; count={scopes.Count}", ct)
.ConfigureAwait(false);
return new KeyActionResult(updated, status);
}
/// <summary>
/// enable-key / disable-key: reversibly toggles a key's active state WITHOUT changing its
/// secret, and appends an <c>enable-key</c> (when enabling) or <c>disable-key</c> (when
/// disabling) audit entry.
/// All attempts are audited, including failures (key not found) — this is intentional to
/// maintain a complete security trail.
/// </summary>
public async Task<KeyActionResult> SetEnabledAsync(
string keyId, bool enabled, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
DateTimeOffset now = _clock.GetUtcNow();
bool updated = await _adminStore.SetEnabledAsync(keyId, enabled, now, ct).ConfigureAwait(false);
string eventType = enabled ? "enable-key" : "disable-key";
string status = updated
? (enabled ? "enabled" : "disabled")
: "not-found";
await AppendAuditAsync(keyId, eventType, remoteAddress, status, ct).ConfigureAwait(false);
return new KeyActionResult(updated, status);
}
private string RequirePepper()
{
string? pepper = _pepperProvider.GetPepper();
@@ -4,7 +4,8 @@ using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete).
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete,
/// set-scopes, enable/disable).
/// </summary>
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
{
@@ -85,6 +86,67 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentNullException.ThrowIfNull(scopes);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET scopes = $scopes
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(scopes));
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
// Reversible toggle: NO `revoked_utc IS NULL` guard (unlike RevokeAsync), so it works
// regardless of current state. Deliberately leaves secret_hash and last_used_utc untouched
// — that is what distinguishes re-enable from RotateAsync.
if (enabled)
{
command.CommandText = """
UPDATE api_keys
SET revoked_utc = NULL
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
}
else
{
command.CommandText = """
UPDATE api_keys
SET revoked_utc = $revoked_utc
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O"));
}
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string keyId, CancellationToken ct)
{
@@ -5,8 +5,15 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// </summary>
public static class SqliteAuthSchema
{
/// <summary>The schema version this build creates and supports.</summary>
public const int CurrentVersion = 1;
/// <summary>
/// The schema version this build creates and supports. This is <c>2</c>, not <c>1</c>,
/// to match the deployed databases of the donor (MxAccessGateway) this store was
/// extracted from: that store reached its final shape via a v1→v2 history and stamps
/// <c>version = 2</c> on disk. The final schema has been byte-identical since v1, so a
/// single-shot create stamped as 2 interoperates with existing <c>gateway-auth.db</c>
/// files (the migrator only refuses an on-disk version <em>newer</em> than this).
/// </summary>
public const int CurrentVersion = 2;
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version";
@@ -35,7 +35,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
}
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await ApplySchemaAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
@@ -78,7 +78,10 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
}
private static async Task ApplyVersionOneAsync(
// Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is
// idempotent against an already-provisioned database). The applied version is stamped
// separately by WriteSchemaVersionAsync.
private static async Task ApplySchemaAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
@@ -9,7 +9,9 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// low-level error on the first real login attempt.
/// </summary>
/// <remarks>
/// Four conditions are enforced:
/// Validation is skipped entirely when <see cref="LdapOptions.Enabled"/> is <c>false</c>
/// (a disabled provider's connection fields are inert). When enabled, four conditions
/// are enforced:
/// <list type="bullet">
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
/// <see cref="LdapOptions.AllowInsecure"/> is explicitly set (dev/test only);</item>
@@ -27,6 +29,14 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
{
ArgumentNullException.ThrowIfNull(options);
// When LDAP is disabled, its connection fields are inert — do not require them.
// A consumer that turns LDAP off should not have to supply a server/search-base/
// service-account just to satisfy startup validation.
if (!options.Enabled)
{
return ValidateOptionsResult.Success;
}
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
{
return ValidateOptionsResult.Fail(
@@ -292,6 +292,59 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
Assert.Equal(auditCountBefore, auditCountAfter);
}
// --- set-scopes / enable-disable ---
[Fact]
public async Task SetEnabledAsync_And_SetScopesAsync_AppendAuditEntries()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await commands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read"], StringComparer.Ordinal),
null,
null,
CancellationToken.None);
// Disable, then re-enable, then replace scopes.
KeyActionResult disabled =
await commands.SetEnabledAsync("key-1", enabled: false, "10.0.0.1", CancellationToken.None);
Assert.True(disabled.Succeeded);
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
KeyActionResult enabled =
await commands.SetEnabledAsync("key-1", enabled: true, "10.0.0.1", CancellationToken.None);
Assert.True(enabled.Succeeded);
Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
KeyActionResult scoped = await commands.SetScopesAsync(
"key-1",
new HashSet<string>(["read", "write"], StringComparer.Ordinal),
"10.0.0.1",
CancellationToken.None);
Assert.True(scoped.Succeeded);
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
Assert.Single(recent, e => e.EventType == "disable-key");
Assert.Single(recent, e => e.EventType == "enable-key");
Assert.Single(recent, e => e.EventType == "set-scopes");
IReadOnlyList<ApiKeyListItem> listed = await commands.ListKeysAsync(CancellationToken.None);
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["read", "write"], StringComparer.Ordinal)));
}
[Fact]
public async Task SetScopesAsync_NullScopes_Throws()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
commands.SetScopesAsync("key-1", null!, null, CancellationToken.None));
}
// --- delete-key ---
[Fact]
@@ -105,6 +105,87 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
Assert.False(result);
}
// --- SetScopes ---
[Fact]
public async Task SetScopesAsync_ReplacesScopes_AndReturnsTrue()
{
await _admin.CreateAsync(
SampleRecord("key-1") with { Scopes = new HashSet<string>(["a"], StringComparer.Ordinal) },
CancellationToken.None);
bool result = await _admin.SetScopesAsync(
"key-1",
new HashSet<string>(["b", "c"], StringComparer.Ordinal),
CancellationToken.None);
Assert.True(result);
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["b", "c"], StringComparer.Ordinal)));
}
[Fact]
public async Task SetScopesAsync_UnknownKey_ReturnsFalse()
{
bool result = await _admin.SetScopesAsync(
"missing",
new HashSet<string>(["b"], StringComparer.Ordinal),
CancellationToken.None);
Assert.False(result);
}
// --- SetEnabled ---
[Fact]
public async Task SetEnabledAsync_False_DisablesKey()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, when, CancellationToken.None);
Assert.True(result);
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.Equal(when, found!.RevokedUtc);
}
[Fact]
public async Task SetEnabledAsync_True_ReenablesKey_WithoutChangingSecret()
{
ApiKeyRecord original = SampleRecord("key-1");
await _admin.CreateAsync(original, CancellationToken.None);
// Record some usage so we can prove last_used_utc is left untouched on re-enable.
var used = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero);
await _read.MarkUsedAsync("key-1", used, CancellationToken.None);
// Disable, then re-enable.
await _admin.SetEnabledAsync(
"key-1", enabled: false, new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
bool result = await _admin.SetEnabledAsync(
"key-1", enabled: true, new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
Assert.True(result);
// Active again, and the secret hash + last-used timestamp are unchanged.
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(active);
Assert.True(active!.SecretHash.SequenceEqual(original.SecretHash));
Assert.Null(active.RevokedUtc);
Assert.Equal(used, active.LastUsedUtc);
}
[Fact]
public async Task SetEnabledAsync_UnknownKey_ReturnsFalse()
{
bool result = await _admin.SetEnabledAsync(
"missing", enabled: false, DateTimeOffset.UtcNow, CancellationToken.None);
Assert.False(result);
}
// --- Delete ---
[Fact]
@@ -172,6 +253,73 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
() => _admin.DeleteAsync(keyId!, CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task SetScopesAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _admin.SetScopesAsync(
keyId!,
new HashSet<string>(["read"], StringComparer.Ordinal),
CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task SetEnabledAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _admin.SetEnabledAsync(keyId!, enabled: false, DateTimeOffset.UtcNow, CancellationToken.None));
}
[Fact]
public async Task SetScopesAsync_NullScopes_ThrowsArgumentNullException()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
await Assert.ThrowsAsync<ArgumentNullException>(
() => _admin.SetScopesAsync("key-1", null!, CancellationToken.None));
}
// --- SetEnabled idempotence ---
[Fact]
public async Task SetEnabledAsync_OnAlreadyActiveKey_ReturnsTrue()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
bool result = await _admin.SetEnabledAsync(
"key-1", enabled: true, DateTimeOffset.UtcNow, CancellationToken.None);
Assert.True(result);
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(active);
Assert.Null(active!.RevokedUtc);
}
[Fact]
public async Task SetEnabledAsync_OnAlreadyDisabledKey_OverwritesTimestamp_ReturnsTrue()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
var t1 = new DateTimeOffset(2026, 5, 1, 10, 0, 0, TimeSpan.Zero);
var t2 = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
// Disable at t1.
await _admin.SetEnabledAsync("key-1", enabled: false, t1, CancellationToken.None);
// Disable again at a later t2 (idempotent overwrite — no guard on revoked_utc).
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, t2, CancellationToken.None);
Assert.True(result);
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
Assert.Equal(t2, item.RevokedUtc);
}
// --- Audit ---
[Fact]
@@ -34,6 +34,27 @@ public sealed class SqliteMigratorTests : IDisposable
Assert.Equal(1, await CountSchemaVersionRowsAsync());
}
[Fact]
public void CurrentVersion_Is2_ToMatchDonorGatewayDeployedSchema() =>
// The store was extracted from MxAccessGateway, whose deployed gateway-auth.db is
// stamped version 2. The library must stamp 2 (not reset to 1) so it does not refuse
// those existing databases on first boot. Locking this invariant.
Assert.Equal(2, SqliteAuthSchema.CurrentVersion);
[Fact]
public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndStaysAt2()
{
// The deployed-gateway scenario: a database already provisioned at version 2.
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
await SetVersionAsync(2);
await migrator.MigrateAsync(CancellationToken.None); // must not throw
Assert.Equal(2, await ReadVersionAsync());
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
}
[Fact]
public async Task MigrateAsync_FutureSchemaVersion_Throws()
{
@@ -72,4 +72,20 @@ public class LdapOptionsValidatorTests
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts())
.Failed);
[Fact]
public void Validator_Skips_AllChecks_WhenDisabled() =>
// When LDAP is disabled its connection fields are inert; an otherwise-invalid
// config (plaintext + blank Server/SearchBase/ServiceAccountDn) must still pass.
Assert.False(new LdapOptionsValidator()
.Validate(null, new LdapOptions
{
Enabled = false,
Transport = LdapTransport.None,
AllowInsecure = false,
Server = "",
SearchBase = "",
ServiceAccountDn = "",
})
.Failed);
}
+13 -4
View File
@@ -3,10 +3,19 @@
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.
> **✅ ADOPTED 2026-06-02 (local-only) — DEEP.** The backlog (#1#6) was implemented across all three apps on each repo's
> **`feat/adopt-zb-audit`** branch (stacked on `feat/adopt-zb-auth`) — committed + spec/code-reviewed, then **merged to
> each repo's local default (main/master) on 2026-06-03; NOT pushed** (origin untouched). The user chose **DEEP adopt**:
> the canonical 9-field `AuditEvent` is the record EVERYWHERE
> (domain fields ride in `DetailsJson`), so the §1 "keep own record" framing below was superseded. OtOpcUa: canonical
> record + `AuditWriterActor : IAuditWriter` + `Outcome` col/migration + `ClusterAudit` fix. MxGateway: canonical SQLite
> `audit_event` store + `IAuditWriter` + `IApiKeyAuditStore`→canonical adapter. **ScadaBridge: a full audit-subsystem
> re-architecture** (codec + site `audit_event`/`audit_forward_state` sidecar + central partitioned-table collapse to
> 10 canonical + persisted computed cols, MSSQL-verified). §5 (Actor→Auth principal) wired via per-app
> `IAuditActorAccessor` (Phase 3). The Task 2.0 gate found this doc's pre-adoption framing was partly stale (MxGateway's
> store had moved into the lib; OtOpcUa's structured path was dormant; ScadaBridge's filter was typed to its own record).
> Detail: `docs/plans/2026-06-02-auth-audit-normalization-phase2-deep.md` + `…-scadabridge-audit-rearch.md`. The
> ⛔/🟡 cells below describe the PRE-adoption divergence (kept for history).
## Divergence vs spec
+9
View File
@@ -3,6 +3,15 @@
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach the shared `ZB.MOM.WW.Auth` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
> **✅ ADOPTED 2026-06-02 (local-only).** The full backlog (#1#8) was implemented across all three apps on each repo's
> **`feat/adopt-zb-auth`** branch — committed + spec/code-reviewed, then **merged to each repo's local default
> (main/master) on 2026-06-03; NOT pushed** (origin untouched; `feat/*` kept). Shared
> `Auth.Ldap` + `Auth.ApiKeys` (ScadaBridge inbound re-architected to keyId/Bearer), `IGroupRoleMapper<TRole>`,
> `Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the
> canonical-six roles (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: OtOpcUa `0.1.1`,
> MxGateway `0.1.2`, ScadaBridge `0.1.3`. Detail: `docs/plans/2026-06-02-auth-audit-normalization*.md`. The ⛔/🟡 cells
> below describe the PRE-adoption divergence (kept for history).
## Divergence vs spec
### §1 LDAP config schema
@@ -99,7 +99,10 @@ public interface IApiKeyStore { // default: SQLite (hash, scope
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct);
Task MarkUsedAsync(string keyId, CancellationToken ct);
}
public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */ }
public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */
Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct); // 0.1.3: replace scope set; secret untouched
Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct); // 0.1.3: reversible enable/disable toggle; secret untouched
}
```
- Constraints are carried as an **opaque `object`** (project supplies the policy: mxaccessgw
@@ -107,6 +110,22 @@ public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audi
parse→lookup→peppered-HMAC→constant-time-compare→audit pipeline; it does **not** interpret constraints.
- Ships the `apikey` admin verbs as a reusable command set.
### 0.1.3 admin additions
`0.1.3` adds **editable scopes** and a **reversible enable/disable toggle** with **no schema
change** (still `CurrentVersion = 2`). Both land on `IApiKeyAdminStore` and the
`ApiKeyAdminCommands` facade:
- `IApiKeyAdminStore.SetScopesAsync(keyId, scopes, ct)` — replaces a key's scope set; never
touches the secret. Returns `false` if the key is unknown.
- `IApiKeyAdminStore.SetEnabledAsync(keyId, enabled, whenUtc, ct)` — clears (`enabled: true`) or
sets (`enabled: false`) `revoked_utc` regardless of current state; leaves `secret_hash` and
`last_used_utc` untouched (the distinction from rotate). Returns `false` if the key is unknown.
- `ApiKeyAdminCommands.SetScopesAsync(...)` — audited `set-scopes` verb (records scope **count**,
not contents); returns `KeyActionResult`.
- `ApiKeyAdminCommands.SetEnabledAsync(...)` — audited `enable-key` / `disable-key` verb;
returns `KeyActionResult`.
## `ZB.MOM.WW.Auth.AspNetCore`
- Canonical `ClaimTypes` constants (name, display, username, role, scope-id).
@@ -0,0 +1,195 @@
# Design — Auth + Audit normalization across all three sister projects
**Date:** 2026-06-02
**Status:** Approved (brainstorming complete) — handing off to writing-plans.
**Scope owner decision:** full two-library normalization (see [Scope decisions](#scope-decisions)).
## Summary
Bring two shared libraries that already live in `scadaproj` but are **unpublished and
adopted by no app** — `ZB.MOM.WW.Auth` (4 packages) and `ZB.MOM.WW.Audit` (1 package) —
to **full adoption across OtOpcUa, MxAccessGateway, and ScadaBridge**, ending with every
audit emit site carrying the genuine Auth-resolved principal as `AuditEvent.Actor`.
The original request was "implement the audit component in all sister projects." Because
audit GAPS #4 (Actor = the `ZB.MOM.WW.Auth` principal) requires an authenticated principal
at every emit site, and because the owner chose the maximal scope at every fork, the job
expands to a **two-library program**: full Auth adoption (auth GAPS #1#8) first, then full
Audit adoption (audit GAPS #1#6) with #4 wiring `Actor` from the now-live principal.
## Verified starting state (source-checked 2026-06-02)
- **Both libraries exist and are pack-ready** in `scadaproj/ZB.MOM.WW.Auth/` (4 csproj +
`build/pack.sh` + `build/push.sh`, 172 tests) and `scadaproj/ZB.MOM.WW.Audit/`
(`build/pack.sh`, 19 tests). Both at version `0.1.0`, both central-package-management.
- **Neither is on the Gitea feed.** All five package registration endpoints return
**HTTP 404**. No `.nupkg` is built locally.
- **Adopted by zero apps.** No sibling repo references `ZB.MOM.WW.Auth*` or `ZB.MOM.WW.Audit`.
- **Feed source-mapping is missing in all three repos.** Each `NuGet.config`
`packageSourceMapping` lists Health/Telemetry/Configuration but **not** Auth or Audit, so
each repo needs mapping lines added (mirror MxGateway commit `437ab65`, which did this for
Configuration).
- **The MxGateway audit coordination gate (audit GAPS #2) is CLEAR.** `MxGateway.Server`
already references `ZB.MOM.WW.Telemetry.Serilog 0.1.0`; the Serilog/Telemetry/Configuration
work is merged to `main`. MxGateway audit adoption is unblocked.
- Established adoption rhythm (Telemetry, Configuration): publish lib to feed → add feed
mapping + version pin → behaviour-preserving consumer cutover → land on the repo's local
default branch (not pushed to remote).
> Per repo memory, prior "published"/"adopted" claims in this workspace have repeatedly been
> optimistic; every claim above was re-verified against the feed and source on 2026-06-02.
## Scope decisions
| Fork | Decision |
|---|---|
| How deep into the audit GAPS backlog? | **Everything incl. #4 Actor→Auth** (all of #1#6). |
| How to satisfy #4 given Auth is unadopted? | **Adopt Auth first, then audit** (two-library program). |
| How much of the Auth backlog? | **Full Auth normalization** (auth GAPS #1#8, all 3 repos). |
| How to walk the work matrix? | **Library-major waterfall** (Phase 1 Auth → Phase 2 Audit → Phase 3 wiring). |
| Remote integration model | **Local-only**; no `git push`, no PRs (safest for production auth paths; flip per repo later if desired). |
## Architecture — four phases
```
Phase 0 Publish & feed-map pack + push both libs to Gitea feed (fix the 404s);
(foundation) add NuGet.config source-mappings + version pins in all 3 repos.
Phase 1 Auth adoption auth GAPS #1#8 across all 3 repos, in GAPS sequence:
(largest, sec-sensitive) #3 IGroupRoleMapper seam → #1 Ldap + #2 ApiKeys cutover →
#4 config schema (A1/A2) + #5 claims/cookies → #6 dev base DN →
#8 canonical roles. Each lands behind tests.
Phase 2 Audit adoption audit GAPS #1#3 core + #5/#6 cleanups across all 3 repos.
(behaviour-preserving)
Phase 3 Actor→Auth wiring audit GAPS #4: route the now-live Auth principal into Actor
(the payoff) at every emit site. Closes the loop Audit.Actor == Auth principal.
```
The waterfall is enforced by task dependencies (Phase 0 → 1 → 2 → 3). Phase 1 must fully
land before Phase 3 can wire a *stable* principal; Phase 2 sits after Phase 1 so emit sites
aren't touched twice.
### Delivery model
- One **feature branch per repo per library phase** (`feat/adopt-zb-auth`, then
`feat/adopt-zb-audit`), behaviour-preserving except where a GAPS item is explicitly net-new.
- **Publish-first**: both packages on the feed and verified resolvable before any consumer edit.
- **Land on each repo's local default branch**, gated by that repo's tests + new contract tests.
- **Local-only** (no push). Each phase is a revertable branch merge.
- The libraries themselves are plain files in `scadaproj` (not nested git repos) — publishing
is `pack` + `push` only; no commits to the libs unless a parity gap forces a fix.
## Phase 0 — publish & feed-map *(task #7)*
1. `dotnet pack -c Release` both libraries; `push.sh` to the Gitea feed
(`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`).
2. Verify all five packages return HTTP 200 from the registration endpoint.
3. In each repo: add `packageSourceMapping` patterns (`ZB.MOM.WW.Auth`, `ZB.MOM.WW.Auth.*`,
`ZB.MOM.WW.Audit`) to the gitea source, and version pins (`Directory.Packages.props` for
OtOpcUa/ScadaBridge; inline `Version="0.1.0"` for MxGateway).
4. `dotnet restore` resolves the new patterns in all three repos.
## Phase 1 — Auth adoption *(task #8, blocked by #7)*
Consumer cutover (libs are already extracted). GAPS order: #3 seam → #1 Ldap + #2 ApiKeys →
#4 config schema + #5 claims/cookies → #6 dev base DN → #8 canonical roles.
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Packages | Abstractions + Ldap + AspNetCore (no ApiKeys — OPC UA transport security) | all 4 (**source** for ApiKeys — cuts over first) | all 4 (**source** for Ldap; ApiKeys consumer after gw) |
| Role mapper (#3) | config-backed (`GroupToRole`) | config-backed | **DB-backed** (`LdapGroupMapping`) |
| Config migration (#4) | A1: `UseTls``Transport` enum (section already nested) | A1: `UseTls``Transport` enum | **A2 (biggest)**: flat `Security:Ldap*`→nested; rename `LdapUserIdAttribute``UserNameAttribute`, `LdapGroupAttribute``GroupAttribute` |
| Cookies/claims (#5) | Blazor Admin control-plane cookie | keep `MxGatewayDashboard` name, share claims | keep `ZB.MOM.WW.ScadaBridge.Auth` name, share claims |
| Canonical roles (#8) | no first-class `Deployer` (publish ⊂ `FleetAdmin`) | no `Designer`/`Deployer` | **roles collapse**: `AuditReadOnly`→Viewer, `Audit`→Administrator (auditor/admin SoD loss — GAPS-accepted) |
**Two deliberate behaviour changes (accepted):**
1. **ScadaBridge API-key token format** (D2): raw `X-API-Key` → structured
`<prefix>_<id>_<secret>`. A genuine wire change for inbound API clients — acceptable
pre-prod, requires an interop check.
2. **Canonical-roles collapse** in ScadaBridge removes auditor/admin separation-of-duties.
**Known live issue to fix during OtOpcUa cutover:** `LdapAuthService` `Enabled`/double-singleton
wiring is still open even though the `Security:Ldap` section binding was fixed — fold the fix
into the OtOpcUa LDAP cutover.
**Risk gate:** parity tests reproducing each app's current authn decisions (bind-then-search,
fail-closed group lookup, RFC-4514 + filter escaping, constant-time compare, peppered
HMAC-SHA256) must be green before any cutover merges.
## Phase 2 — Audit adoption *(task #9, blocked by #8)*
Behaviour-preserving seam/record/enum adoption.
| Repo | Core work (GAPS #1#3) | Keep bespoke |
|---|---|---|
| **OtOpcUa** (#1, #5) | Replace `Commons/.../AuditEvent.cs` with canonical record; `AuditWriterActor : IAuditWriter`; derive `Outcome` at emit sites (`OpcUaAccessDenied`/`CrossClusterNamespaceAttempt`→Denied, config verbs→Success); bridge `NodeId`/`CorrelationId` value-types | Akka singleton transport, 500/5s batching, two-layer dedup, `ConfigAuditLog` EF entity + idempotency index |
| **MxGateway** (#2, #6) | Map `IApiKeyAuditStore`/`ApiKeyAuditEntry``IAuditWriter`/`AuditEvent`; generate `EventId`; `"system"`/`"cli"` Actor fallback; `Category="ApiKey"`; `constraint-denied`→Denied | SQLite store, 3 producer call sites (only injected type changes), append-only table |
| **ScadaBridge** (#3) | Outright rename `IAuditPayloadFilter``IAuditRedactor`; adopt canonical `AuditOutcome` enum; confirm writer contract (byte-identical) — keep bespoke ~25-field record as storage shape | Entire Site/Central pipeline, 4 domain enums, CLI export/verify, Blazor UI, redaction policy |
**Resolved open GAPS decisions:**
1. **ScadaBridge rename vs. alias****outright rename** (compiler-verified across the HIGH blast radius).
2. **MxGateway `Details`→`DetailsJson`****wrap as a small JSON object** (keeps the field valid JSON).
3. **OtOpcUa `Outcome` storage****new nullable `Outcome` column + EF migration** (first-class, queryable).
4. **OtOpcUa SP path****leave bespoke + document**; *do* fix the `ClusterId`-filter/actor
mismatch in `ClusterAudit.razor` so structured rows are visible.
**Cleanups in scope:** #5 (OtOpcUa SP reconcile + `ClusterId` visibility fix), #6 (MxGateway
`CorrelationId` capture + structured `Target`).
**Behaviour fix:** MxGateway's `AppendAsync` currently may propagate; wrap it so the adopted
`IAuditWriter` never throws (honors the best-effort contract).
## Phase 3 — Actor→Auth wiring *(task #10, blocked by #8 + #9)*
With Auth live (Phase 1) and the canonical record adopted (Phase 2), route the resolved
principal into `AuditEvent.Actor` everywhere:
- **Seam:** one small `IAuditActorAccessor` — HTTP paths read `HttpContext.User`; non-HTTP
paths (Akka actors, CLI) thread the operation principal or fall back. The single place that
changes if the principal source ever changes again.
- OtOpcUa → LDAP-resolved user. MxGateway → API-key name (system/cli fallback retained for
keyless CLI events). ScadaBridge → principal at `ManagementActor`/inbound boundary.
## Contracts, testing & risk gates
**Hard seam contracts:**
- `IAuditWriter` — best-effort, MUST NOT throw, swallow internal failures. OtOpcUa actor ✅,
ScadaBridge ✅; MxGateway needs the never-throw wrap (above).
- `IAuditRedactor` — pure, never throws, over-redacts on failure. ScadaBridge's
`SafeDefaultAuditPayloadFilter` is the reference; rename preserves it.
**Cross-boundary surface:** Auth/Audit adoption is in-process and does **not** touch the
cross-repo wire contracts (gateway `.proto` files, OPC UA address-space shape) — **except** the
ScadaBridge API-key token-format change, the one item needing an interop check rather than just
a green unit build. A green build in one repo does not prove interop.
**Per-phase verification (evidence before "done"):**
- **Phase 0:** all 5 packages HTTP 200; `dotnet restore` green in all 3 repos.
- **Phase 1:** existing auth tests + new parity tests green per repo before merge; SB
token-format integration check.
- **Phase 2:** existing audit tests + new `Outcome`/`EventId`/rename tests; OtOpcUa `Outcome`
migration applies forward.
- **Phase 3:** `Actor == authenticated principal` on authenticated paths; fallback retained on
keyless/system paths.
- **Library suites** (Audit 19, Auth 172) re-run if any lib is touched. If a parity gap forces
a lib fix, bump `0.1.0``0.1.1` and re-publish rather than editing a published version.
## Tasks
| Task | Item | Blocked by |
|---|---|---|
| #7 | Phase 0 — publish both libs + feed-map all 3 repos | — |
| #8 | Phase 1 — adopt ZB.MOM.WW.Auth across all 3 repos (auth GAPS #1#8) | #7 |
| #9 | Phase 2 — adopt ZB.MOM.WW.Audit across all 3 repos (audit GAPS #1#3, #5, #6) | #8 |
| #10 | Phase 3 — wire Actor from the Auth principal (audit GAPS #4) | #8, #9 |
## References
- `components/auth/GAPS.md`, `components/auth/spec/`, `components/auth/current-state/*`
- `components/audit/GAPS.md`, `components/audit/shared-contract/ZB.MOM.WW.Audit.md`,
`components/audit/current-state/*`
- Libraries: `ZB.MOM.WW.Auth/`, `ZB.MOM.WW.Audit/`
- Prior adoption precedent: `components/configuration/GAPS.md`,
`components/observability/GAPS.md`
@@ -0,0 +1,366 @@
# Phase 1 (Auth adoption) — elaborated steps + Task 1.0 findings
Companion to `2026-06-02-auth-audit-normalization.md`. Produced by the Task 1.0 read-only
exploration gate (4 parallel explorers: library surface + 3 repos). All paths verified
2026-06-02 against source.
## Cutover target — `ZB.MOM.WW.Auth` public surface
| Package | Consumer entry points |
|---|---|
| `.Abstractions` | **NB: `IGroupRoleMapper<TRole>`/`GroupRoleMapping<TRole>`/`CanonicalRole` live in namespace `ZB.MOM.WW.Auth.Abstractions.Roles`** (verified during Task 1.1). `ILdapAuthService`, `LdapOptions` (`Transport: LdapTransport{Ldaps,StartTls,None}`, `AllowInsecure`, `UserNameAttribute`, `GroupAttribute`, `ServiceAccountDn/Password`, `SearchBase`, `ConnectionTimeoutMs`, `ServerCertificateValidationCallback`), `LdapAuthResult(Succeeded,Username,DisplayName,Groups,Failure)`, `LdapAuthFailure`, `CanonicalRole{Viewer,Operator,Engineer,Designer,Deployer,Administrator}`, `IGroupRoleMapper<TRole>` (**no default impl — consumer writes it**) → `GroupRoleMapping<TRole>(Roles, Scope:object?)`, plus API-key abstractions (`IApiKeyVerifier`, `ApiKeyVerification`, `ApiKeyIdentity`, `IApiKeyStore`/`IApiKeyAdminStore`/`IApiKeyAuditStore`, `ApiKeyOptions{TokenPrefix,PepperSecretName,SqlitePath,RunMigrationsOnStartup}`) |
| `.Ldap` | `LdapAuthService(LdapOptions)` : `ILdapAuthService`. Bind-then-search, fail-closed, never throws. `LdapOptionsValidator` (TLS-or-AllowInsecure) auto-registered. |
| `.ApiKeys` | `ApiKeyVerifier(ApiKeyOptions, IApiKeyStore, IApiKeyPepperProvider, TimeProvider?)`, `ApiKeyParser.TryParse` (`<prefix>_<keyId>_<secret>`), `ApiKeySecretGenerator.NewSecret()`, default SQLite stores, `ConfigurationApiKeyPepperProvider`. **Extracted from MxGateway — near-1:1 with its pipeline.** |
| `.AspNetCore` | `ZbClaimTypes{Name,Role,DisplayName,Username,ScopeId}`, `ZbCookieDefaults.Apply(opts, requireHttps, idleTimeout)`, DI: `AddZbLdapAuth(services, config, sectionPath)`, `AddZbApiKeyAuth(services, config, sectionPath)`. |
## Per-app current state (verified) and elaborated cutover
### OtOpcUa — packages: Abstractions + Ldap + AspNetCore (no ApiKeys)
Current LDAP: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs` (impl), `ILdapAuthService.cs`,
`LdapOptions.cs` (**section `Security:Ldap`**, `UseTls` bool, `Enabled`, `DevStubMode`, embedded `GroupToRole` dict),
`LdapAuthResult.cs` (already carries `Roles`). Role mapping is **config + DB**: `RoleMapper.Map` (config
`GroupToRole`) + `RoleMapper.Merge` with DB `LdapGroupRoleMappingService`/`LdapGroupRoleMapping` (system-wide rows).
Native roles `AdminRole{ConfigViewer,ConfigEditor,FleetAdmin}` (control-plane only; data-plane is a separate
`NodePermissions` bitmask). DI: two `TryAddSingleton<ILdapAuthService,LdapAuthService>` sites
(`Security/ServiceCollectionExtensions.cs:42` + `Host/Program.cs:106`). Cookie `ZB.MOM.WW.OtOpcUa.Auth`,
single Cookie scheme (JWT inside cookie). **Second LDAP consumer:** OPC UA data-plane
`LdapOpcUaUserAuthenticator` + `OpcUaApplicationHost.HandleImpersonation` call the LDAP service too.
- **1.1 mapper:** implement `IGroupRoleMapper<AdminRole>` (or `<string>`) wrapping `RoleMapper.Map` + DB `Merge`.
- **1.2 Ldap:** replace `LdapAuthService` with `Auth.Ldap`; restructure flow to `ILdapAuthService → Groups → IGroupRoleMapper → roles → claims`; **preserve `DevStubMode` app-side** (library has no stub); wire BOTH consumers (login endpoint + OPC UA impersonation).
- **1.4 config:** `UseTls``Transport` enum (section already `Security:Ldap` — see Finding #1).
- **1.5 cookie/claims:** use `ZbClaimTypes` + `ZbCookieDefaults.Apply`; keep cookie name.
- **1.7 roles:** `ConfigViewer→Viewer`, `ConfigEditor→Designer`, `FleetAdmin→Administrator(+Deployer; publish⊂FleetAdmin)`. Data-plane `NodePermissions` unaffected.
### MxAccessGateway — packages: all 4 (ApiKeys **source**, cuts over first)
Current API keys (`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`): `ApiKeyParser` (`mxgw_<id>_<secret>`),
`ApiKeySecretHasher` (HMAC-SHA256 + pepper `MxGateway:ApiKeyPepper`), `ApiKeySecretGenerator`, `ApiKeyVerifier`
(`FixedTimeEquals`), SQLite stores, `ConstraintEnforcer` + rich `ApiKeyConstraints`, gRPC
`GatewayGrpcAuthorizationInterceptor` + `GatewayScopes`. DI `AddSqliteAuthStore()`. → **near-1:1 with `Auth.ApiKeys`.**
LDAP: `Dashboard/DashboardAuthenticator.cs` (`MxGateway:Ldap`, `UseTls`), `GroupToRole` under `MxGateway:Dashboard`,
roles `Admin`/`Viewer`, cookie `MxGatewayDashboard`.
- **1.1 mapper:** `IGroupRoleMapper<string>` wrapping `DashboardAuthenticator.MapGroupsToRoles`.
- **1.2 Ldap:** replace `DashboardAuthenticator`'s LDAP internals with `Auth.Ldap` (keep dashboard claims/principal build).
- **1.3 ApiKeys:** delete the local parser/hasher/generator/verifier/stores; re-point to `Auth.ApiKeys`; **keep** `ConstraintEnforcer` + gRPC interceptor + scopes on top (constraints carried as the opaque blob). Lowest-risk ApiKeys cutover (it's the donor).
- **1.4 config:** `UseTls``Transport`.
- **1.5/1.7:** `ZbClaimTypes`/cookie defaults; `Viewer→Viewer`, `Admin→Administrator`.
### ScadaBridge — packages: all 4 (Ldap **source**; ApiKeys consumer)
Current LDAP (`src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs`): the hardened reference (RFC-4514 DN escape,
filter escape, per-op timeout, fail-closed group lookup, username trim, service-account-bind distinction). Config is
**flat** `ScadaBridge:Security:Ldap*` in `SecurityOptions.cs` with **`LdapTransport` enum already** (`Ldaps/StartTls/None`),
`AllowInsecureLdap`, `LdapUserIdAttribute`, `LdapGroupAttribute`, validated by `SecurityOptionsValidator : OptionsValidatorBase`.
Role mapping **DB-backed** with **site-scoping**: `RoleMapper.MapGroupsToRolesAsync``RoleMappingResult(Roles, PermittedSiteIds, IsSystemWideDeployment)` over `LdapGroupMapping` + `SiteScopeRule` (SQL Server). Roles
`Admin/Design/Deployment/Audit/AuditReadOnly`; SoD via `OperationalAudit{Admin,Audit,AuditReadOnly}` + `AuditExport{Admin,Audit}`.
Cookie `ZB.MOM.WW.ScadaBridge.Auth`; JWT-in-cookie via `JwtTokenService`.
**Inbound API keys** (`InboundAPI/ApiKeyValidator.cs`): **raw `X-API-Key`**, **deterministic** HMAC (`ApiKeyHasher`, no per-row salt, by-value lookup), `ApiKey{Name,KeyHash,IsEnabled}` in **SQL Server**, **per-method approval** via `ApiMethod.ApprovedApiKeyIds` — **architecturally different from the library's keyId/scope/SQLite model.**
- **1.1 mapper:** `IGroupRoleMapper<string>` wrapping `RoleMapper.MapGroupsToRolesAsync`, carrying `PermittedSiteIds`/`IsSystemWideDeployment` in `GroupRoleMapping.Scope`.
- **1.2 Ldap:** ScadaBridge is the donor — confirm `Auth.Ldap` behaviour-matches, then re-point `LdapAuthService` usages to the library type. Lowest-risk Ldap cutover.
- **1.3 ApiKeys:** **see Finding #3 — bigger than a token reformat; needs a scope decision.**
- **1.4 config:** nest flat `Security:Ldap*` under a sub-section + rename `LdapUserIdAttribute→UserNameAttribute`, `LdapGroupAttribute→GroupAttribute`, `LdapTransport→Transport` (+ `SecurityOptionsValidator` + appsettings). Enum already matches.
- **1.7 roles:** `Admin→Administrator`, `Design→Designer`, `Deployment→Deployer`, `Audit→Administrator` (collapse), `AuditReadOnly→Viewer` (collapse) — removes the `OperationalAudit`/`AuditExport` SoD (accepted).
## Key findings that change the plan
1. **OtOpcUa LDAP section is `Security:Ldap`, not `Authentication:Ldap`.** Both `components/auth/GAPS.md §1`
and the auth current-state doc are wrong; the code (and the prior fix in memory) use `Security:Ldap`.
→ Task 1.4 for OtOpcUa is only `UseTls``Transport`, not a section move.
2. **OtOpcUa "double-singleton bug" is already mitigated.** Both registration sites use `TryAddSingleton`
(dedupes); the `Enabled` flag is an intentional fail-closed master switch. → Not a blocking fix; verify and
keep `Enabled`. Removes a risk the plan flagged.
3. **ScadaBridge inbound API keys are a re-architecture, not a token reformat.** The library's ApiKeys model
(`<prefix>_<keyId>_<secret>` Bearer, keyId lookup + constant-time compare, SQLite store, scopes + opaque
constraints) is fundamentally different from ScadaBridge's (raw `X-API-Key`, deterministic by-value HMAC
lookup, SQL Server `ApiKey{Name,KeyHash}`, per-method approval list). Wholesale adoption means re-architecting
inbound-API auth AND resolving a SQLite-vs-SQL-Server storage mismatch. **Needs a scope decision (Decision A).**
4. **OtOpcUa role mapping is config + DB**, not just config (`RoleMapper.Map` baseline + DB `Merge`). The
`IGroupRoleMapper` impl must combine both. OtOpcUa also has `DevStubMode` (no library equivalent — keep app-side)
and a **second LDAP consumer** (OPC UA data-plane impersonation) that must be re-wired too.
5. **MxGateway ApiKeys cutover is the donor path — lowest risk** (delete locals, re-point to library; keep
`ConstraintEnforcer`/gRPC/scopes on top). Confirms the GAPS sequencing (gateway first).
## Task 1.2 (LDAP cutover) — implemented + reviewed (2026-06-02)
Commits: OtOpcUa `257caa7`, MxGateway `c3b466e`, ScadaBridge `ac34dac`. All targeted tests green.
Security review verdict: **sound, no credential-leak regression** in any repo (insecure-transport
guards fire correctly; DevStubMode cannot leak to prod; claim shapes preserved). All three returned
CHANGES-REQUESTED for fixable issues:
- **OtOpcUa** (no Critical): (I1) insecure-transport guard is login-time only — add startup
validation gated on `Enabled` for defense-in-depth, verify prod overlays still boot; (I2) integration
stub pre-populates `Roles` so the Groups→mapper path isn't actually exercised — fix the stub; (I3)
document/test the zero-role fail-closed fallback.
- **MxGateway** (2 Critical): (C1) library strips group DNs to short RDN names before the
`LdapGroupClaimType` claim → verify prior behaviour, document, drop the now-dead full-DN branch in the
mapper, add a claim-value assertion; (C2) gateway's local `LdapOptions` is now a shadow copy (validated
but unused at runtime) → fold to the shared type or document the drift. (I1) shared `LdapOptionsValidator`
has **no `Enabled=false` guard** → validates even when LDAP is disabled (real for MxGateway, which can
disable dashboard LDAP).
- **ScadaBridge** (2 Critical): (C1) `ConfigSecretsTests` still checks the OLD flat key → passes
vacuously, no longer guards secret-in-config — repoint to nested key; (C2) `production-checklist.md`
still lists deleted flat keys → update; (I) unsafe `(RoleMappingResult)Scope!` cast → null-guard.
**Cross-cutting decision — shared library `LdapOptionsValidator` `Enabled` guard:** the validator runs
regardless of `Enabled`, requiring Server/SearchBase/ServiceAccountDn even when LDAP is off. Correct fix =
add an `if (!Enabled) return Success` guard to the shared validator and republish `0.1.1`, re-pinning all
consumers. (Alternative: each consumer always supplies those fields. The library fix is the principled one.)
## Task 1.2/1.4 — DONE (reviewed + fixed, 2026-06-02)
Library hardened to **`0.1.1`** (`LdapOptionsValidator` skips when `Enabled=false`), republished, re-pinned in all 3 repos.
Fix commits: OtOpcUa `c4f315e` (startup insecure-transport guard gated on Enabled/DevStub + `Transport: Ldaps`
declared in the 3 prod overlays + test fidelity), MxGateway `f4dc11b` (group-claim shape documented as
non-breaking — claim read nowhere in prod; shadow `LdapOptions` kept with a drift-warning doc), ScadaBridge
`4db8c37` (secret-test repointed to nested key, prod checklist updated, `Scope` cast guarded). All targeted
suites green. **1.2 (LDAP) + 1.4 (config) complete across all 3 repos.**
Remaining Phase 1: **1.3 ApiKeys** (MxGateway donor cutover — low risk; ScadaBridge full re-architecture —
largest single item: SQLite store + Bearer format + scopes + key re-issuance), **1.5** claims/cookies,
**1.6** dev base DN, **1.7** canonical roles.
## Task 1.3 ApiKeys — MxGateway DONE; ScadaBridge pending (2026-06-02)
**Library bumped to `0.1.2`**: `Auth.ApiKeys` SQLite migrator now stamps schema version **2** (was 1) to
match the donor gateway's deployed `gateway-auth.db` — without it the gateway would fail to boot (migrator
threw on a newer on-disk version). Final schema byte-identical since v1; no key re-issuance. Republished,
re-pinned in MxGateway. (+2 migrator tests.)
**MxGateway 1.3 — DONE + APPROVED** (commit `05009d7`): deleted 28 local pipeline files, adopted
`Auth.ApiKeys 0.1.2` via `AddZbApiKeyAuth`; kept `ConstraintEnforcer`/gRPC interceptor/scopes/CLI/dashboard
on top via a `GatewayApiKeyIdentityMapper` (library identity → gateway identity-with-EffectiveConstraints).
Review: no Critical; no auth bypass, schema compat + crypto parity + gRPC status mapping verified. Non-blocking
follow-ups: (a) dashboard mutations now write two audit rows (library + `dashboard-*`) — fine, note for Phase 2
audit bridging; (b) nit: `GatewayApiKeyIdentityMapper` uses `Constraints as string` (opaque coupling) — consider
a guard/contract test.
**ScadaBridge 1.3 — PENDING**: the full inbound-API re-architecture (SQL Server → SQLite store, `X-API-Key`
→ Bearer, per-method-approval → scopes/constraints, **all inbound keys re-issued**). Largest/highest-risk
single item in the program; warrants its own focused pass (likely decomposed).
## ScadaBridge ApiKeys re-architecture — spec (FULL ADOPT, 2026-06-02)
Decision: **full adopt** the library SQLite store + scopes model. Single consistent contract all layers build to:
- **Token format**: `Authorization: Bearer sbk_<keyId>_<secret>` (prefix `sbk`). Replaces the raw `X-API-Key` header.
- **Scope model = method name.** A key's `Scopes` set = the API-method names it may call. `ApiMethod.ApprovedApiKeyIds`
(CSV of key int IDs) is **retired**; per-method approval moves to the key's scopes. Auth check at the endpoint:
`identity.Scopes.Contains(methodName)`.
- **Storage**: inbound keys move to the library's SQLite store (new `ScadaBridge:InboundApi:ApiKeyStore` sqlite path
+ pepper via `ApiKeyOptions.PepperSecretName`, `RunMigrationsOnStartup`). The SQL Server `ApiKey` entity is retired;
`ApiMethod` is KEPT minus `ApprovedApiKeyIds` (EF migration drops the column). `InboundApiRepository` loses its ApiKey
methods + `GetApprovedKeysForMethodAsync`.
- **Auth path** (`InboundAPI`): endpoint reads Bearer, calls library `IApiKeyVerifier.VerifyAsync`, then the scope check.
PRESERVE the security invariants: 401 (missing/invalid/disabled), **403 identical message for both "method not found"
and "not in scope"** (enumeration-safety, InboundAPI-011), constant-time compare (library does it), active-node 503 +
body-cap 413 filters unchanged, audit actor = key DisplayName. Delete `ApiKeyValidator` hashing + `ApiKeyHasher`.
- **Management** (`ManagementActor` + CLI `security api-key` + Commons messages): drive the library `IApiKeyAdminStore` +
`ApiKeySecretGenerator`. `create` returns `sbk_<keyId>_<secret>` once (plaintext-once preserved); methods a key may call
= its scopes, set on create/update (e.g. `--methods a,b` or grant/revoke-method commands). `list` returns id/name/enabled
(no secret), `update --enabled`, `delete`/revoke. Audit preserved.
- **CentralUI**: `ApiKeys.razor` (list/create/toggle/delete via admin store; show token once), `ApiKeyForm.razor` (edit the
key's method-scopes), `ApiMethodForm.razor` (method-side "approved keys" now reads/writes key scopes across keys).
- **Breaking change**: all inbound keys re-issued (new format); clients switch `X-API-Key``Authorization: Bearer`.
Needs a runbook + CHANGELOG. Re-pin ScadaBridge Auth packages to **0.1.2**.
Sub-tasks (sequential where files overlap): **(A)** storage retire + EF migration + library wiring/options;
**(B)** auth-path rewrite (Bearer + verifier + scope check); **(C)** management (ManagementActor + CLI + messages);
**(D)** CentralUI pages; **(E)** runbook/CHANGELOG + integration test sweep. A→(B,C)→D→E.
Sequencing note: doing it **additively** (add library path, switch auth, rewire mgmt/UI, retire SQL Server entity LAST)
keeps the build green at each step.
### Re-arch progress
- **A+B foundation — DONE + reviewed+fixed** (commits `a94558c`, `1fcc4f5`; re-pinned to 0.1.2). Library `AddZbApiKeyAuth`
wired additively (`ScadaBridge:InboundApi:ApiKeyStore`, prefix `sbk`, reuses inbound pepper); inbound endpoint now uses
the library verifier + Bearer + `Scopes.Contains(methodName)`. Security invariants preserved: 401 generic / 403 identical
body for not-found AND not-in-scope (enumeration-safe, pinned to a literal in tests), scope-check-before-DB (no timing
oracle), fail-fast pepper preflight (Central), audit actor = DisplayName. Old SQL Server path still compiles (retired in E).
163/163 InboundAPI tests green. **NOTE for E:** the library's `ApiKeySecretGenerator.NewSecret()` is `internal` — seed/create
keys via the public `ApiKeyAdminCommands.CreateKeyAsync` seam (returns the assembled `sbk_…` token).
- **Library 0.1.3 — DONE + reviewed + PUBLISHED** (scadaproj commits `468959c` impl, `290e85c` tests; pushed to Gitea,
ApiKeys 0.1.3 nupkg verified HTTP 200). Added `IApiKeyAdminStore.SetScopesAsync(keyId, scopes, ct)` + `SetEnabledAsync(keyId,
enabled, whenUtc, ct)` (+ audited facade verbs `ApiKeyAdminCommands.SetScopesAsync`/`SetEnabledAsync` → eventTypes
`set-scopes`/`enable-key`/`disable-key`). **No schema change** (`CurrentVersion` stays 2): scopes column already exists;
`revoked_utc` doubles as the enabled flag (null = enabled), so enable/disable is a reversible toggle that preserves the
secret (proven by test asserting `SecretHash.SequenceEqual` + unchanged `last_used_utc`). This is what lets C/D edit a key's
method-scopes and toggle enabled WITHOUT re-issuing the token. **ScadaBridge must re-pin Auth packages 0.1.2 → 0.1.3.**
- **C (management), D (CentralUI), E (retire SQL Server ApiKey + ApiMethod.ApprovedApiKeyIds migration + runbook/CHANGELOG)
— IN PROGRESS.** Mapping: `CreateApiKeyCommand``CreateKeyAsync` (keyId = `Guid.NewGuid().ToString("N")`,
DisplayName = name, scopes = `--methods`); `ListApiKeysCommand``ListKeysAsync` (enabled = `RevokedUtc is null`);
`UpdateApiKeyCommand(IsEnabled)``SetEnabledAsync`; new set-scopes path → `SetScopesAsync`; `DeleteApiKeyCommand`
revoke-then-`DeleteKeyAsync`. All management message keys switch `int ApiKeyId``string KeyId`.
### Discovered architecture (CentralUI Explore, 2026-06-02) — expands C/D/E
Two facts the original AE spec missed:
1. **CentralUI bypasses the ManagementActor.** `Components/Pages/Admin/ApiKeys.razor`, `ApiKeyForm.razor`, and
`Components/Pages/Design/ApiMethodForm.razor` call `IInboundApiRepository` (SQL Server EF) **directly** — they do NOT
send the `CreateApiKeyCommand`/etc. management messages. So there are **two** management entry points to rewire
(CLI→ManagementActor uses the messages; CentralUI→repository uses the entities). Decoupling: introduce one app-side
**`IInboundApiKeyAdmin` seam** over the library `ApiKeyAdminCommands`, and route BOTH CLI and CentralUI through it
(DRY + single audit path). The message-contract change (int→string) touches only CLI+ManagementActor; the
entity/repository change (`ApiKey.Id`, `ApiMethod.ApprovedApiKeyIds`) touches CentralUI + TransportExport.
2. **TransportExport couples API keys + methods into config export/import** (`Components/Pages/Design/TransportExport.razor`
+ `.razor.cs`, `HashSet<int>` selections, `ExportSelection`). With keys now in the library SQLite store (per-env pepper,
secret-once), a key can't be exported/re-imported usefully. **Decision (user, 2026-06-02): EXCLUDE inbound API keys from
transport — export API methods only; keys are re-created + method-scopes re-granted per environment.**
CentralUI blast radius (string keyId + scopes replace int Id + ApprovedApiKeyIds CSV): `Admin/ApiKeys.razor`,
`Admin/ApiKeyForm.razor`, `Design/ApiMethodForm.razor` (approved-keys ↔ key-scopes), `Design/TransportExport.razor(.cs)`,
`Design/ExternalSystems.razor` (uses method `int` id — methods STAY int in SQL Server, so unaffected for keys),
`Dashboard.razor` (key count), test `Admin/ApiKeyFormAuditDrillinTests.cs`.
### C/D/E decomposition — 5 reviewed green sub-commits (user: "coordinated multi-commit now", 2026-06-02)
- **C1** — re-pin ScadaBridge Auth 0.1.2→0.1.3; add app-side `IInboundApiKeyAdmin` seam (string-keyId model:
Create(name,methods)→(keyId,token) / List / SetEnabled / SetMethods / Delete[=revoke+delete] / GetMethodsForKey /
GetKeysForMethod) over the library facade; register `ApiKeyAdminCommands` + the seam in Host **and** CentralUI DI; seam
unit tests. **Purely additive — build green.**
- **C2** — Commons `Messages/Management/SecurityCommands.cs` contracts int→string keyId + add `Methods` + new
`SetApiKeyMethodsCommand`; rewire ManagementActor handlers + CLI `security api-key` onto the seam; update ManagementActor
tests. (CentralUI unaffected — it doesn't use these messages.)
- **C3** — CentralUI `ApiKeys.razor`/`ApiKeyForm.razor`/`ApiMethodForm.razor` (+ Dashboard count) off `IInboundApiRepository`-
for-keys onto the seam; string keyId; method-scope editing replaces `ApprovedApiKeyIds`; update bUnit test. (Methods stay
in SQL Server; just stop using the `ApprovedApiKeyIds` column — dropped in C5.)
- **C4** — TransportExport: remove API-key selection/export (methods-only); drop key `HashSet<int>` + `ExportSelection` keys;
tests.
- **C5 (=E)** — retire SQL Server `ApiKey` entity + DbContext reg + `IInboundApiRepository` key methods +
`GetApprovedKeysForMethodAsync`; drop `ApiMethod.ApprovedApiKeyIds`; EF migration (drop ApiKeys table + column); delete
residual `ApiKeyValidator`/`ApiKeyHasher`; runbook + CHANGELOG (breaking: re-issue keys, `X-API-Key``Authorization: Bearer`);
full build+test sweep.
#### Re-arch sub-commit progress (2026-06-02)
- **C1 — DONE + reviewed** (ScadaBridge commits `d09def2` seam+re-pin-0.1.3, `7f7ea3f` review polish). `IInboundApiKeyAdmin`
seam (interface in Commons, `LibraryInboundApiKeyAdmin` impl in the Security project over `ApiKeyAdminCommands`), DI in
Host (CentralUI shares that container). Spec PASS + code-review APPROVED (guard `name`, doc throws/O(n) contract).
**Two pre-existing Host.Tests reds from the prior session's Auth work (uncaught because Host.Tests weren't run) fixed as
part of restoring a green baseline:** (a) `7e25efa` — A+B's Central pepper preflight (`1fcc4f5`) needs a ≥16-char test
`ApiKeyPepper`; supplied via env vars in the Central test fixtures (test-only) + 3 guard tests; Host.Tests 86 fail → 1.
(b) `55099b1` — LDAP cutover (`ac34dac`) made component-lib `AddSecurity(IConfiguration)` violate ScadaBridge's
`OptionsTests` arch rule; moved `AddZbLdapAuth` to the Host composition root, dropped the param (behaviour-preserving);
Host.Tests 1 fail → **0**. Green baseline now: build 0/0, Host.Tests 228, Security.Tests 89, InboundAPI 163, CentralUI 584.
**NOTE for Phase 2:** `AuditLog.AddAuditLog(IConfiguration)` also takes IConfiguration but is intentionally NOT in the
`OptionsTests` scanned set — revisit during audit adoption (Task 2.5), don't silently "fix".
- **C2 — DONE + reviewed** (SB commits `6518e93` rewire, `8219b8e` review fixes). Commons messages int→string keyId
+ `Methods` + new `SetApiKeyMethodsCommand`; ManagementActor's 5 API-key handlers + CLI `security api-key` now drive
`IInboundApiKeyAdmin`; ScadaBridge management audit preserved (actor = user.Username; secret/token never audited/logged).
Spec PASS, code-review APPROVED after fixes: not-found now throws `ManagementCommandException` BEFORE audit (no spurious
audit on no-op update/delete/set-methods); empty `Methods` rejected server-side (prevents unusable key on create + stealth-
disable via `set-methods ""`); token advisory→stderr. Green: ManagementService 125, CLI 188, + Security/InboundAPI/Host/
CentralUI unchanged. CentralUI + SQL Server `ApiKey` entity/repo untouched (C3/C5).
- **C3 — DONE + reviewed** (SB commits `107e524` rewire, `d1191fd` review fixes). CentralUI `Admin/ApiKeys.razor`,
`Admin/ApiKeyForm.razor`, `Design/ApiMethodForm.razor`, `Dashboard.razor` onto `IInboundApiKeyAdmin`: string keyId,
method-NAME scopes replace the `ApprovedApiKeyIds` CSV, one-time token display on create, key Name fixed-after-create
(no rename in the lib model). The "approved keys ↔ key scopes" inversion is a pure tested helper
`CentralUI/Services/ApiMethodKeyScopeReconciler.cs` (save method entity first, then reconcile each affected key's full
scope set fresh; empty-last-scope revoke is blocked with a clear message, never pushes an empty set). Spec PASS,
code-review APPROVED after fixes: seam `bool` not-found now surfaced (no silent success), partial-reconcile-failure
guidance ("method saved, key scopes partially applied — review on API Keys page"), create validation order, concurrent-
edit reconciler test. CentralUI.Tests 595 green; all other suites unchanged. TransportExport + SQL Server entities/repo
untouched (C4/C5). (Also removed a stray `Name` artifact file from an accidental redirect — not committed.)
- **C4 — DONE + reviewed** (SB commits `731cfd3` rewire, `b13d7b3` review polish). TransportExport excludes inbound API
keys (methods-only) end-to-end — UI selection, `ExportSelection`, DependencyResolver, EntitySerializer/DTOs, BundleExporter,
manifest/summary, CLI `--api-keys`, ManagementActor `HandleExportBundle`, and the IMPORT path (BundleImporter/ArtifactDiff:
no key creation; method overwrite PRESERVES the destination's existing `ApprovedApiKeyIds`, doesn't clobber). Method export
drops `ApprovedApiKeyIds`. Backward-compat: legacy bundles with an `apiKeys` section still deserialize (tolerant `ApiKeys?`
field via shared `BundleJsonOptions` + `WhenWritingNull`) and are IGNORED on import with an `ImportResult.ApiKeysIgnored`
count + audit stamp; new exports omit the field. UI info note added. Spec PASS, code-review APPROVED (note: review I-1
"added-unrestricted count" intentionally SKIPPED — wrong model: inbound auth is scope-based, the verifier ignores
`ApprovedApiKeyIds`, so a new method is callable by NO key until a scope is granted). Transport.Tests 60, IntegrationTests
34 green. SQL Server `ApiKey`/`ApiMethod` entities + repo untouched (C5).
- **C5 (=E) — DONE + reviewed** (SB commit `afa5598`). Retired SQL Server `ApiKey` entity + 7 `IInboundApiRepository` key
methods + `ApiMethod.ApprovedApiKeyIds` + `DbSet<ApiKey>`/fluent config + residual `ApiKeyHasher`/`IApiKeyHasher`/
`ApiKeyValidator` (+ their tests). EF migration `RetireInboundApiKeyStore` (DropTable `ApiKeys` + DropColumn
`ApprovedApiKeyIds`; `Down` recreates both byte-faithfully; ModelSnapshot consistent). CHANGELOG.md + tracked runbook
`docs/operations/inbound-api-key-reissue.md` (BREAKING: `X-API-Key``Authorization: Bearer sbk_…`, all keys re-issued;
per-env SqlitePath + ≥16-char ApiKeyPepper). Spec PASS, code-review APPROVED: migration Down/snapshot verified, inbound
verifier path (A+B) intact, no live consumer broke. Green: ConfigurationDatabase 241, InboundAPI 148 (was 163: removed
validator/hasher tests), Security 89, Host 227 (was 228: removed validator DI test), ManagementService 125, CLI 188,
CentralUI 595, Transport 60+34. (Pre-existing infra-dependent failures — IntegrationTests ×11, AuditLog ×1, needing live
LDAP/SQL/SMTP — proven identical at baseline `b13d7b3` via git-stash; StaleTagMonitor flaky timer tests pass 13/13 isolated.)
**Installer/secret note:** the C5 code-review flagged the (untracked, intentionally `.gitignore`d `/deploy/`) `install.ps1`
not injecting the pepper — fixed ON DISK (the on-disk installer now takes `-ApiKeyPepper`); a subagent had force-committed
the ignored deploy script (which embeds a real default JWT key) — that commit was RESET (`git reset --mixed`), keeping the
edit on disk and the secret OUT of git history (branch was never pushed). The pepper requirement is documented in the
tracked runbook.
### ✅ Task 1.3 (Adopt ZB.MOM.WW.Auth.ApiKeys) COMPLETE across all repos
MxGateway donor cutover + ScadaBridge full re-architecture (C1 seam → C2 mgmt/CLI → C3 CentralUI → C4 TransportExport →
C5 retire+migration+runbook), all reviewed, lib at **0.1.3**. ScadaBridge inbound API is now 100% on the shared library
(Bearer `sbk_<keyId>_<secret>`, scope = method name, per-key SQLite store + per-env pepper); the SQL Server key model is
fully retired. Remaining Phase 1: **1.5** (AspNetCore claims/cookies, 3 UIs), **1.6** (dev GLAuth base DN), **1.7**
(canonical roles, 3 repos). Then Phase 2 (audit) + Phase 3 (Actor wiring).
## Resolved decisions (2026-06-02)
- **Decision A — ScadaBridge inbound API keys depth → (a) FULL ADOPT.** Re-architect inbound-API auth to the
library's model: `<prefix>_<keyId>_<secret>` Bearer token format, keyId lookup + constant-time compare,
scopes/constraints, and **move inbound API keys into the library's SQLite store** (separate from the SQL Server
config DB). This is the largest, highest-risk item in Phase 1. Implications to handle in Task 1.3:
- New SQLite auth DB for ScadaBridge inbound keys (path via `ApiKeyOptions.SqlitePath`); migrate/retire the
SQL Server `ApiKey{Name,KeyHash}` table + `ApiMethod.ApprovedApiKeyIds` linkage.
- Re-model **per-method approval** as the library's scopes/constraints (or the opaque constraint blob) — the
`ApiMethod.ApprovedApiKeyIds` set becomes per-key scope grants.
- Switch the inbound transport from `X-API-Key` header to `Authorization: Bearer <token>` (a client-visible
contract change — extends the already-accepted token-format change; needs the interop check + a doc/CHANGELOG note).
- Existing raw keys cannot be migrated (deterministic-by-value hash, no keyId/secret split) → **re-issue** all
inbound API keys; call this out in the cutover runbook.
- **Decision B — canonical role mappings → confirmed as tabled above** (OtOpcUa `ConfigViewer→Viewer`,
`ConfigEditor→Designer`, `FleetAdmin→Administrator+Deployer`; MxGateway `Viewer/Admin`; ScadaBridge
`Admin→Administrator`, `Design→Designer`, `Deployment→Deployer`, `Audit→Administrator`, `AuditReadOnly→Viewer`).
- **Decision C — dev escape hatches → keep app-side, unchanged.** OtOpcUa `DevStubMode` and MxGateway
`AllowAnonymousLocalhost`/loopback bypass have no library equivalent; preserve them in each app outside the
shared `Auth.Ldap` path.
## Phase 1 tail — decisions + current state (2026-06-02, resumed)
Task 1.0 gate read-only re-exploration confirmed the post-cutover state for 1.5/1.6/1.7 (3 parallel Explore agents):
- **None of the 3 repos reference `ZbClaimTypes`/`ZbCookieDefaults` yet.** `ZbClaimTypes.Name`/`Role` alias the framework
URIs (`ClaimTypes.Name`/`.Role`); `DisplayName`/`Username`/`ScopeId` = new `zb:`-prefixed strings.
- Claim mints today: **OtOpcUa** `AuthEndpoints.cs` uses `ClaimTypes.NameIdentifier` + `JwtTokenService.{Username,DisplayName}ClaimType` ("Username"/"DisplayName") + `ClaimTypes.Role` (JWT-in-cookie). **MxGateway** `DashboardAuthenticator.CreatePrincipal` uses `ClaimTypes.{NameIdentifier,Name,Role}` + custom `mxgateway:ldap_group`. **ScadaBridge** `CentralUI/Auth/AuthEndpoints.cs` + `JwtTokenService` use **plain** `"DisplayName"/"Username"/"Role"/"SiteId"/"LastActivity"` strings — `"Role"`/`"SiteId"` are load-bearing in `TokenValidationParameters` + every `AuthorizationPolicies` `RequireClaim`.
- Cookie names confirmed: `ZB.MOM.WW.OtOpcUa.Auth` / `MxGatewayDashboard` / `ZB.MOM.WW.ScadaBridge.Auth`. All three apps already do HttpOnly+SameSite=Strict+sliding+SecurePolicy via hand-rolled `PostConfigure` (no `ZbCookieDefaults.Apply`).
- Dev base DNs today: OtOpcUa + MxGateway = `dc=lmxopcua,dc=local`; ScadaBridge = `dc=scadabridge,dc=local`.
- `CanonicalRole` is referenced **nowhere** in any repo yet (Task 1.7 is its first use).
**Decision A3 (Task 1.6 dev base DN) → `dc=zb,dc=local`** (product-neutral, matches the ZB.MOM.WW family; all 3 dev
fixtures + dev appsettings move to it — prod directories untouched). ScadaBridge GLAuth user DNs become
`cn=<user>,ou=<group>,ou=users,dc=zb,dc=local`; OtOpcUa/MxGateway leave `dc=lmxopcua`.
**Decision (Task 1.5 ScadaBridge depth) → FULL canonical incl. role/scope.** Migrate ScadaBridge's role claim to the
framework URI (`ZbClaimTypes.Role`) and the site claim to `ZbClaimTypes.ScopeId` across cookie + JWT mint +
`TokenValidationParameters` + every policy `RequireClaim` + tests (cleanest: redefine the `JwtTokenService.*ClaimType`
constants to alias `ZbClaimTypes.*` so all existing references inherit canonical values). **Treated as high-risk** for the
ScadaBridge slice (serial spec→code review, full ScadaBridge suite). OtOpcUa/MxGateway slices stay standard.
### ✅ Task 1.5 (AspNetCore claims/cookies) COMPLETE across all 3 repos (reviewed)
- **OtOpcUa** `83856b7` + review-fix `d0777ee` (spec ✅, code ✅): `.Security` adds the `Auth.AspNetCore` pkg ref; `JwtTokenService.{Username,DisplayName}ClaimType` alias `ZbClaimTypes.{Username,DisplayName}`; cookie principal emits `ZbClaimTypes.Name` (replaced `NameIdentifier` — grep-confirmed no other reader) + `ZbClaimTypes.Role`; cookie via `ZbCookieDefaults.Apply`, name kept. Issued JWT is documented as issue-only (no `AddJwtBearer` in OtOpcUa; role stays short `"Role"`; `BuildValidationParameters` pins `RoleClaimType`/`NameClaimType` for forward-compat). 35/35.
- **MxGateway** `7e1af37` (spec ✅, code ✅): `DashboardAuthenticator` emits `ZbClaimTypes.{Username,DisplayName}` + identity `nameType/roleType=ZbClaimTypes.{Name,Role}`; keeps `mxgateway:ldap_group` + `NameIdentifier` (HubTokenService reads it); cookie via `ZbCookieDefaults.Apply(requireHttps:true, idleTimeout:8h)` (8h preserved), `RequireHttpsCookie=false` dev-HTTP override kept, name kept. Dashboard 85/85; full 575/578 (3 pre-existing FakeWorker reds).
- **ScadaBridge** `a0938f7` + spelling-fix `c185a56` (high-risk; spec ✅, code ✅): `JwtTokenService.*ClaimType` constants aliased to `ZbClaimTypes.*` (`RoleClaimType`=framework URI, `SiteIdClaimType`=`ScopeId`); JWT mint `MapInboundClaims=false`+`OutboundClaimTypeMap.Clear()` (instance-isolated, reviewer-verified) and validate `MapInboundClaims=false`+pinned `RoleClaimType`/`NameClaimType` → byte-symmetric round-trip; cookie identity `roleType=RoleClaimType`; every site-scope read on `SiteIdClaimType`; cookie via `ZbCookieDefaults.Apply` (30-min idle), name kept. No `AddJwtBearer` middleware (sole JWT path = `JwtTokenService.ValidateToken`). Role VALUES unchanged. Security 93/93, CentralUI 595/595, ManagementService 125/125, Host 227/227; infra reds (Integration ×11, AuditLog ×1, flaky StaleTagMonitor) confirmed pre-existing by stash-at-HEAD. **Minor (deferred):** a stale "PostConfigure" comment word; JWT-validated principals have null `Identity.Name` (no regression, no bearer path).
### ✅ Task 1.6 (unify dev LDAP base DN → `dc=zb,dc=local`) COMPLETE across all 3 repos (reviewed, code-review-only per `small` class)
Mechanical, grep-verified substitution of each repo's dev directory base DN to the neutral `dc=zb,dc=local`; prod left untouched (no in-repo prod overlay carries the dev DN; `/deploy` is gitignored and was not touched). OU structure preserved throughout.
- **OtOpcUa** `8ba289f`: `LdapOptions.SearchBase` default, integration `docker-compose.yml` `LDAP_ROOT` + `TwoNodeClusterHarness` SearchBase/ServiceAccountDn, `AclEdit.razor` placeholder, `docs/v2/{dev-environment,phase-7-e2e-smoke}`. `grep dc=lmxopcua`→empty. Security 35, AdminUI 121, ControlPlane 29, Runtime 74 green.
- **MxGateway** `9572045`: `LdapOptions` defaults, `appsettings.json`, dashboard test group-DNs, `glauth.md` (dev DNs only — the `DC=corp,…` prod-example column left intact), `CLAUDE.md` index line. `grep dc=lmxopcua`→empty. 575/578 (3 pre-existing FakeWorker).
- **ScadaBridge** `6ae6051` (14 files): app `appsettings.Central.json`, the 4 docker/docker-env2 central-node configs, `infra/glauth/config.toml` baseDN, `infra/tools/ldap_tool.py`, 4 test fixtures, `docs/test_infra/*`. Cluster nodes use the shared `scadabridge-ldap` container backed by the now-updated `infra/glauth/config.toml` (no separate seed). `grep dc=scadabridge`→only the 2 excluded historical `docs/plans/*` records + synthetic `dc=example` left. Full non-infra suite green (Security 93, CentralUI 595, ManagementService 125, Host 227, ConfigurationDatabase 241).
## Task 1.7 (canonical roles) — inventory + decisions (2026-06-02)
Read-only role inventory (3 parallel Explore agents) found the canonical-role standardization is bigger than the plan's "~5 min/repo": it changes role string VALUES (claims + config-DB + enforcement), needs config-DB DATA migrations, and makes the ScadaBridge SoD collapse real. **EF persistence confirmed:** OtOpcUa `AdminRole` is `HasConversion<string>().HasMaxLength(32)` (stores the enum MEMBER NAME); ScadaBridge `LdapGroupMappings.Role` is free-text `nvarchar(500)` with HasData seed. Both → renaming role values requires a data migration.
**Resolved per-repo mapping (Decision B + filled gaps):**
- **MxGateway:** `Viewer→Viewer` (no-op), `Admin→Administrator`. Clean rename of `DashboardRoles.Admin` VALUE + `GroupToRole` config + `GatewayOptionsValidator` allowed-set. NO DB (dashboard roles not persisted). ⚠️ MUST NOT touch the separate gRPC `GatewayScopes.Admin = "admin"` data-plane scope.
- **OtOpcUa:** `ConfigViewer→Viewer`, `ConfigEditor→Designer`, `FleetAdmin→Administrator`, **`DriverOperator→Operator`** (plan-omitted gap). Rename `AdminRole` members + DevStub/appsettings `GroupToRole` values + every `[Authorize(Roles=)]`/`RequireRole` role string. **Config-DB data migration** on `LdapGroupRoleMappings.Role` (raw SQL UPDATE old→new; column is the same string col so it's a data, not schema, change). Data-plane `NodePermissions` bitmask UNTOUCHED. Enforcement preserved: `Designer`(←ConfigEditor) keeps the deploy access it has today (`Deployments.razor` `Roles="FleetAdmin,ConfigEditor"``"Administrator,Designer"`). Policy NAMES (e.g. `"DriverOperator"`/`"FleetAdmin"` policy keys) may stay as internal indirections; only the role STRINGS they check become canonical.
- **ScadaBridge (heaviest):** `Admin→Administrator`, `Design→Designer`, `Deployment→Deployer`, **`Audit→Administrator`** (collapse), **`AuditReadOnly→Viewer`** (collapse). Requires: config-DB data migration (`LdapGroupMappings.Role` UPDATE + HasData seed + ModelSnapshot); ~20 hard-coded role-string sites (ManagementActor site-scope bypass ×6 + `GetRequiredRole`, DebugStreamHub ×2, BrowseService/BindingTester, policy arrays); SoD policy rework `OperationalAuditRoles→{Administrator,Viewer}` + `AuditExportRoles→{Administrator}` so former `AuditReadOnly`(→Viewer) keeps audit-READ but still can't export; all role-asserting tests. **Real security consequence (accepted):** `Audit→Administrator` grants former audit-only users the full admin surface (create sites, manage LDAP mappings/API keys, import bundles). Site-scoping stays orthogonal (computed from `PermittedSiteIds`, Deployment-only).
**Decisions (2026-06-02):** depth = **FULL canonical (values change, incl. config-DB migrations + real SoD escalation)**; cadence = **proceed now**. Execution: MxGateway + OtOpcUa single high-risk commits each (parallel); ScadaBridge as a focused atomic change (12 coupled commits — the rename + seed + migration are coupled, so it does not cleanly split into 1.3-style green sub-increments). High-risk serial review (spec→code) per repo + full ScadaBridge suite.
### ✅ Task 1.7 (canonical roles) COMPLETE across all 3 repos (high-risk; spec ✅ + code ✅ each)
- **MxGateway** `04bce3ff` (spec ✅, code ✅): `DashboardRoles.Admin` value `"Admin"→"Administrator"` (Viewer unchanged) + `GroupToRole` config; validator/enforcement inherit the constant. NO DB (dashboard roles not persisted). gRPC `GatewayScopes.Admin="admin"` proven untouched. 577/580 (3 pre-existing FakeWorker).
- **OtOpcUa** `c1619d9` (spec ✅, code ✅): `AdminRole` enum members → `Viewer/Designer/Administrator`; `DriverOperator` role string → `Operator` (policy NAMES kept stable); DevStub `["Administrator"]`. **Data migration** `20260602112419_CanonicalizeAdminRoles` (`UPDATE LdapGroupRoleMapping` old→new, reverse Down, snapshot unchanged, no pending model changes). `Deployments.razor` `[Authorize(Roles="Administrator,Designer")]` (deploy access preserved). Data-plane `NodePermissions`/`NodeAcl`/evaluator untouched (proven). Security 45, Configuration 90, AdminUI 121 green. (Minor non-issues: an `ou=FleetAdmin` placeholder DN + a data-plane doc-comment — both LDAP-group/doc text, not role values.)
- **ScadaBridge** `b104760` + doc-fix `4118452` (high-risk; spec ✅, code ✅): `Roles` → canonical `{Administrator,Designer,Deployer,Viewer}` (Audit/AuditReadOnly removed); **SoD reworked** `OperationalAudit={Administrator,Viewer}`, `AuditExport={Administrator}` (Viewer reads-not-exports audit; Administrator does both + full admin). All enforcement literals moved incl. the 6 ManagementActor site-scope bypasses + DebugStreamHub + BrowseService/BindingTester. **Migration** `20260602113822_CanonicalizeRoles` (seed `UpdateData` + idempotent raw catch-all for operator rows; lossy Down documented; snapshot consistent). **Real SoD escalation** (Audit→Administrator gains full admin) documented in CHANGELOG. Full non-infra suite green (Security 93, CentralUI 595, ManagementService 125, Host 227, ConfigurationDatabase 241); infra reds pre-existing (stash-at-HEAD confirmed). `4118452` corrected stale role-name prose in NavMenu comments (comment-only; CentralUI rebuild 0/0).
## ✅ PHASE 1 COMPLETE (2026-06-02)
All of Tasks 1.01.7 done across OtOpcUa, MxAccessGateway, ScadaBridge — each on its local-only `feat/adopt-zb-auth` branch, **nothing pushed**. The three apps now consume `ZB.MOM.WW.Auth.*` from the Gitea feed (OtOpcUa 0.1.1 Abstractions+Ldap+AspNetCore; MxGateway 0.1.2 all-four; ScadaBridge 0.1.3 all-four): shared LDAP (`Auth.Ldap`), shared API-key model (`Auth.ApiKeys`, ScadaBridge fully re-architected), `IGroupRoleMapper<TRole>` seam, nested/`Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Every task spec- and code-reviewed; high-risk ones via the serial chain + full-suite runs. **Phase 1 exit gate met.** Next: Phase 2 (audit component — the original ask) starting at the Task 2.0 gate, then Phase 3 (wire audit Actor from the Auth principal).
@@ -0,0 +1,208 @@
# Phase 2 (Audit adoption) — Task 2.0 gate findings + DEEP re-scope (for review)
Companion to `2026-06-02-auth-audit-normalization.md`. Produced by the **Task 2.0 read-only
verification gate** (3 parallel explorers, all paths verified 2026-06-02 against live code on each
repo's `feat/adopt-zb-auth` HEAD). **Status: PAUSED for user review before any audit code is written.**
**Decisions taken (2026-06-02):**
- **Depth = DEEP adopt (canonical record).** Each app's audit record becomes the library's 9-field
`ZB.MOM.WW.Audit.AuditEvent`; domain-specific fields relocate into `DetailsJson`; each app consumes
the library's `IAuditWriter`/`IAuditRedactor`/`AuditOutcome` types. (User chose this over the
gate-recommended lighter "Align" — consistent with the standing maximal/full-adopt directive.)
- **Cadence = re-scope + PAUSE for review.** This doc is the review artifact; implementation does not
start until the user signs off (especially on the ScadaBridge cost, below).
> **Why a re-scope was needed:** the plan's Phase 2 task specs were written from optimistic
> `components/audit/current-state/*` docs (see [[component-status-claims-are-optimistic]]). The gate
> found all three repos' specs are materially off — file refs moved (MxGateway), the target path is
> dormant (OtOpcUa), and the "outright rename" is structurally impossible (ScadaBridge).
---
## The canonical contract (shared `ZB.MOM.WW.Audit` 0.1.0)
`AuditEvent` (sealed record): REQUIRED `EventId:Guid`, `OccurredAtUtc:DateTimeOffset` (UTC-normalized
on set), `Actor:string`, `Action:string`, `Outcome:AuditOutcome`; OPTIONAL `Category:string?`,
`Target:string?`, `SourceNode:string?`, `CorrelationId:Guid?`, `DetailsJson:string?`. **Nine fields.**
`AuditOutcome { Success, Failure, Denied }`. `IAuditWriter.WriteAsync(AuditEvent, CancellationToken)`
best-effort, never throws. `IAuditRedactor.Apply(AuditEvent) -> AuditEvent` — pure, never throws.
The package is pinned (central PM / explicit) + feed-mapped in all three repos; **referenced by none yet.**
---
## OtOpcUa — DEEP (Tasks 2.1 + 2.2) · risk: LOWMEDIUM
**Verified current state:** Commons `AuditEvent` is an **8-field positional record**
`(Guid EventId, string Category, string Action, string Actor, DateTime OccurredAtUtc, string? DetailsJson,
NodeId SourceNode, CorrelationId CorrelationId)` — where `NodeId`/`CorrelationId` are `readonly record
struct` newtypes over `string`/`Guid`. It is an **Akka message** delivered via `DistributedPubSub`
(`provider=cluster`) with **default (reflection) serialization** — no custom serializer. **The structured
actor path is DORMANT: zero production emit sites** construct/`Tell` an `AuditEvent` today (only the tests
do); all live audit goes through the bespoke **stored-procedure path** (`sp_NodeApplied`/`sp_PublishGeneration`/
`sp_ValidateDraft`/`sp_RollbackToGeneration` INSERT directly with `ClusterId`/`GenerationId`, NULL `EventId`).
`AuditWriterActor` (`ControlPlane/Audit/AuditWriterActor.cs`): 500/5s batching, two-layer dedup (in-buffer
`Dictionary<Guid,AuditEvent>` + DB filtered-unique `UX_ConfigAuditLog_EventId`), mapping at `:75-84`.
`ConfigAuditLog` (10 cols, no `Outcome`; `ISJSON` CHECK on `DetailsJson`). `ClusterAudit.razor:78` filters
`a.ClusterId == ClusterId`, but the actor sets `NodeId` not `ClusterId`, so structured rows are invisible.
Package pinned `0.1.0` in `Directory.Packages.props`, feed-mapped, unreferenced.
**Deep design — this is the easy one (the record is already ~canonical):**
- **2.1 (high-risk: actor + contract):** Delete Commons `AuditEvent.cs`; reference `ZB.MOM.WW.Audit.AuditEvent`
from `ZB.MOM.WW.OtOpcUa.Commons` + `…ControlPlane`. Field map: `EventId``EventId`; `OccurredAtUtc`
`DateTime``DateTimeOffset` (widen at construction); `Actor`/`Action`/`Category`/`DetailsJson` direct;
`SourceNode` (unwrap `NodeId.Value``string?`); `CorrelationId` (unwrap `.Value``Guid?`); `Target` unused
(null) — OtOpcUa has no extra domain fields to push into `DetailsJson`, so **no field relocation**. Add the
NEW required `Outcome` (derive: `OpcUaAccessDenied`/`CrossClusterNamespaceAttempt``Denied`; config verbs→
`Success`; no `Failure` in OtOpcUa's vocabulary). `AuditWriterActor : IAuditWriter` (`WriteAsync` wraps the
fire-and-forget `Tell`, returns `Task.CompletedTask` — trivially best-effort). Keep batching/dedup. Mapping
at `:75-84` becomes `NodeId = evt.SourceNode`, `CorrelationId = evt.CorrelationId`, `Outcome = evt.Outcome`,
`EventType = $"{evt.Category}:{evt.Action}"` (storage keeps the composite). Value-type unwrap happens at the
(test + future) construction sites. **Akka wire note:** the message type changes shape → a rolling-deploy
wire break IN PRINCIPLE, but **moot** (no live emit traffic). Flag in the commit; no dual-accept window needed.
- **2.2 (high-risk: EF migration + UI query):** add nullable `Outcome` to `ConfigAuditLog` (+ DbContext mapping
`:429-463`) + EF migration `AddConfigAuditLogOutcome` (chains after `20260602112419_CanonicalizeAdminRoles`).
Fix `ClusterAudit.razor:78` so `ClusterId == null && NodeId` resolves to the cluster (OR-predicate joining
`ClusterNodes`, or populate `ClusterId` at flush). SP path stays bespoke (documented).
- **Package refs:** `…Commons` (record + `AuditOutcome`), `…ControlPlane` (`IAuditWriter`), `…Configuration`
(only if `Outcome` is stored as the enum type; otherwise store `string?`/`int?` and skip).
- **Effort:** ~record swap 5m + actor seam 5m + Outcome derivation 5m (2.1); column+migration+query 5m (2.2).
---
## MxGateway — DEEP (Task 2.3, re-scoped) · risk: MEDIUMHIGH (was "standard")
**Verified current state — the plan's file refs are STALE:** Phase 1 (Task 1.3) **moved**
`IApiKeyAuditStore` + `ApiKeyAuditEntry` + `SqliteApiKeyAuditStore` **into the shared library**
(`ZB.MOM.WW.Auth.Abstractions`/`…ApiKeys` 0.1.2) — they no longer exist in MxGateway. `ApiKeyAuditEntry` =
**5 fields** `(string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details)`,
persisted to the SQLite `api_key_audit` table (5 cols). `IApiKeyAuditStore` = `AppendAsync` + `ListRecentAsync`
(the dashboard "recent audit" view reads via `ListRecentAsync`). **Three producers, but one is library-internal:**
- `ApiKeyAdminCommands` (**library-internal**, in `ZB.MOM.WW.Auth.ApiKeys`) — emits CLI/admin verbs
(`init-db`/`create-key`/`revoke-key`/`rotate-key`/`delete-key`/`set-scopes`/`enable-key`/`disable-key`),
keyless for `init-db`, `RemoteAddress` null on the CLI path. **MxGateway cannot edit these call sites.**
- `DashboardApiKeyManagementService` (MxGateway-local) — `dashboard-*` verbs, real `KeyId` + `RemoteAddress`.
- `ConstraintEnforcer.RecordDenialAsync` (MxGateway-local) — single `constraint-denied` EventType, `RemoteAddress`
hardcoded null, `Details = "{commandKind}: {target}: {ConstraintName}: {Message}"`.
`AppendAsync` currently **propagates** exceptions (no best-effort wrap). Serilog migration **landed** (no blocker).
`ZB.MOM.WW.Audit` unreferenced; `nuget.config` already maps the package.
**Deep design — the library-internal CLI producer forces an adapter:**
- Add `<PackageReference Include="ZB.MOM.WW.Audit" />` to `…Server`.
- New **MxGateway-owned canonical store** `audit_event` (SQLite, 9 canonical columns + `details_json`) with its own
migrator — the existing `api_key_audit` lives in the **library-owned** auth DB schema, so we do NOT alter that
schema. Implement `IAuditWriter` over the new store (best-effort try/catch — fixes the no-wrap gap).
- **Adapter for the library-internal CLI events:** register a MxGateway `IApiKeyAuditStore` impl whose
`AppendAsync(ApiKeyAuditEntry)` maps → canonical `AuditEvent` (`EventId=NewGuid`; `KeyId``Actor` with
`"cli"`/`"system"` fallback; `EventType``Action`; `CreatedUtc``OccurredAtUtc`; `RemoteAddress``SourceNode`;
`Outcome=Success`; `Category="ApiKey"`; `Target=KeyId`; `Details``DetailsJson` wrapped `{"detail":"…"}`) and
forwards to `IAuditWriter`. Its `ListRecentAsync` reads the canonical store and maps back to `ApiKeyAuditEntry`
(so the existing dashboard recent-audit view keeps working) **or** the dashboard view is repointed to canonical.
- **Local producers** (`DashboardApiKeyManagementService`, `ConstraintEnforcer`) rewritten to build canonical
`AuditEvent`s directly via `IAuditWriter` (`constraint-denied``Outcome.Denied`; capture `CorrelationId` from
`MxCommandRequest.ClientCorrelationId` (constraint path — needs threading down) / `HttpContext.TraceIdentifier`
(dashboard); structured `Target` from `commandKind`/`target` (GAPS #6)).
- **Open question for review:** retire `api_key_audit` (canonical store becomes the sole audit table) vs keep it
coexisting. Retiring is cleaner-deep but touches the library's store wiring; coexisting is lower-risk.
- **Effort/classification:** re-scoped from "standard ~5m" to **high-risk** (new store + migrator + adapter +
producer rewrites + dashboard read path + DI + tests). Realistically 23 sub-commits.
---
## ScadaBridge — DEEP (Task 2.5, re-scoped) · risk: **VERY HIGH — audit-subsystem re-architecture**
**This is the one to scrutinize at review.** The gate definitively answered the plan's central claim is FALSE.
**Verified current state:** ScadaBridge's `AuditEvent` (`…Commons/Entities/Audit/AuditEvent.cs`) is a
**24-field** record — `EventId, OccurredAtUtc(DateTime), IngestedAtUtc, Channel(AuditChannel), Kind(AuditKind),
CorrelationId, ExecutionId, ParentExecutionId, SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor,
Target, Status(AuditStatus), HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary,
PayloadTruncated, Extra, ForwardState(AuditForwardState?)`. It is the **storage shape of a partitioned SQL Server
audit table** with these as **queryable columns**. `IAuditPayloadFilter.Apply(ScadaBridgeAuditEvent) ->
ScadaBridgeAuditEvent` (NOT the library's record — a reflection contract test `PayloadFilterContractTests` pins
the typing). `IAuditWriter`/`ICentralAuditWriter` are likewise typed to the 24-field record. **`AuditStatus`
drives the site→central forwarding STATE MACHINE** (`Pending→Submitted→Forwarded→Reconciled`;
`Delivered`/`Failed`/`Parked`/`Discarded`) and the **filter's error-cap logic** (`IsErrorStatus`). The Central
reporting/UI queries by `Channel`/`Kind`/`Status`/`Site`. **Phase 1 did NOT touch any audit-pipeline file** (zero
drift). Blast radius of just the interface rename: ~10 files / ~20 sites; the contract test pins it.
**What DEEP adoption concretely requires here (full honesty):**
Replacing the 24-field record with the 9-field canonical + pushing ~15 domain fields into `DetailsJson` means
**re-architecting the entire audit subsystem**, because those fields are not decorative — they are load-bearing:
1. **Storage:** migrate the partitioned SQL Server audit table from ~24 typed columns to the 9 canonical columns
+ a JSON `DetailsJson` column. Massive, lossy-on-queryability data migration; partitioning scheme likely must
change; `IngestedAtUtc`/`ForwardState` are operational columns the forwarder UPDATEs.
3. **Forwarding state machine breaks:** `Status`/`ForwardState` move into opaque JSON — you cannot `UPDATE` a
JSON-embedded field as a column, and the reconciliation queries `WHERE Status/ForwardState = …` stop working.
The site→central forwarder would have to be redesigned (e.g., promote Status back out of JSON, defeating the
point).
4. **Redactor breaks:** `DefaultAuditPayloadFilter` reads `Channel`/`Status`/`RequestSummary`/`ResponseSummary`/
`ErrorDetail`/`Extra`/`PayloadTruncated` to choose truncation caps — on a 9-field canonical record those are
gone (opaque in `DetailsJson`), so the filter must be rewritten to parse JSON.
5. **Reporting/UI breaks:** Central audit-log queries/filters by Channel/Kind/Status/Site lose SQL queryability.
6. ~Dozens of call sites + the contract test + the perf hot-path test.
**Honest assessment:** ScadaBridge DEEP ≈ the **largest single undertaking in the whole program** (bigger than the
Phase-1 ApiKeys re-arch). The audit component's own GAPS doc says *"Align, don't replace"* for exactly this reason.
**Bounded alternative to weigh at review (recommended if "deep" is to be kept tractable):** make the canonical
`ZB.MOM.WW.Audit.AuditEvent` the **seam/transport + cross-project reporting** shape (the redactor and an
`IAuditWriter` operate on the canonical record; domain richness rides in `DetailsJson`), while the **SQL storage
keeps its typed queryable columns** populated by a storage-side projection (canonical+DetailsJson → columns) and
the forwarding state machine continues to key on the `Status`/`ForwardState` columns. This delivers "deep" at the
seam/record level (library types consumed; domain fields in `DetailsJson` for the canonical view) **without**
gutting the partitioned store, the state machine, the filter, or the reporting — a far safer "deep."
---
## Cross-cutting
- **Branch model:** `feat/adopt-zb-audit` per app, **stacked on `feat/adopt-zb-auth` HEAD** (Phase 3 wires the
audit `Actor` from the Phase-1 Auth principal, so audit must build on auth). Local-only, never pushed.
- **No library change / republish** needed for the chosen designs (MxGateway adapts in-repo) — so no Gitea token
required unless the user later wants the canonical mapping pushed into a shared lib.
- **Phase 3 (unchanged in intent):** `IAuditActorAccessor` seam + wire `AuditEvent.Actor` from the Auth principal
at every authenticated emit site; keep `"system"`/`"cli"` fallbacks for keyless paths.
## Re-scoped task list (for review)
| # | Repo | Re-scoped scope | Class | Risk |
|---|---|---|---|---|
| 2.1 | OtOpcUa | Commons record → canonical `AuditEvent`; `AuditWriterActor : IAuditWriter`; `Outcome` derivation; Akka-wire note (dormant) | high-risk | LowMed |
| 2.2 | OtOpcUa | `ConfigAuditLog.Outcome` column + EF migration + `ClusterAudit` visibility fix; SP path bespoke | high-risk | LowMed |
| 2.3 | MxGateway | new canonical SQLite `audit_event` store + migrator; `IAuditWriter`; `IApiKeyAuditStore`→canonical adapter (for library-internal CLI events) incl. `ListRecentAsync`; rewrite local producers; CorrelationId/Target capture; DI; tests | **high-risk** (↑ from standard) | MedHigh |
| 2.5 | ScadaBridge | **DEEP = audit-subsystem re-arch** (24-field→9-field record everywhere; domain fields→`DetailsJson`; SQL partitioned-table migration; forwarding state machine + filter + reporting rewrite; contract/perf tests) — **OR** the bounded "deep-at-the-seam" alternative above | **very-high-risk** | **VERY HIGH** |
## Implementation status (2026-06-02, deep adoption underway)
- **✅ OtOpcUa 2.1 + 2.2 DONE** (`feat/adopt-zb-audit`, spec ✅ + code ✅): `933dd1a` — deleted bespoke Commons
`AuditEvent`, adopted library `ZB.MOM.WW.Audit.AuditEvent`, `AuditWriterActor : IAuditWriter` (best-effort
`WriteAsync` wraps `Self.Tell`), `AuditOutcomeMapper.FromAction` derivation, batching/dedup intact; `b7f5e88`
nullable `Outcome` column + migration `20260602135350_AddConfigAuditLogOutcome` (additive, chains after
CanonicalizeAdminRoles, no pending model changes) + `ClusterAudit` fix via shared `ClusterAuditQuery` (OR-predicate
joining `ClusterNode` membership). SP path untouched. ControlPlane 45/45, Configuration 80/80 (+3), AdminUI 121/121.
Minor backlog: no `IX_ConfigAuditLog_NodeId` (irrelevant while structured path dormant).
- **✅ MxGateway 2.3 DONE** (`feat/adopt-zb-audit`, spec ✅ + code ✅): `a5944bb` — new MxGateway-owned canonical
SQLite `audit_event` store (same auth DB file via the library's `AuthSqliteConnectionFactory`; library tables
untouched), `CanonicalAuditWriter : IAuditWriter` (best-effort, never throws — closes the library's no-wrap gap),
`CanonicalForwardingApiKeyAuditStore : IApiKeyAuditStore` adapter (maps `ApiKeyAuditEntry`→canonical w/ system/cli
fallback + constraint-denied→Denied + DetailsJson wrap; `ListRecent` round-trips for the dashboard view), DI
overrides the library's `TryAddSingleton`'d store; `7ea8358` — Dashboard + ConstraintEnforcer rewritten to emit
canonical `AuditEvent` directly via `IAuditWriter` with structured `Target` + (dashboard) `CorrelationId`. 587 pass,
3 pre-existing FakeWorker reds, +10 tests. `api_key_audit` left unused (documented). Minor backlog: dup `WrapDetail`,
per-op `EnsureTable`, a test temp-dir leak, unfiltered `ListRecent` category.
- **✅ ScadaBridge 2.5 — DONE (FULL re-arch, user-chosen).** Decomposed into C1C7 (design in
`2026-06-02-scadabridge-audit-rearch.md`), all spec+code reviewed, MSSQL-verified, local-only on `feat/adopt-zb-audit`.
Canonical record everywhere; site SQLite two-table (canonical + forwarding sidecar); central `dbo.AuditLog` collapsed to
10 canonical cols + persisted computed cols (`CollapseAuditLogToCanonical` migration); redactor/outcome/UI/export/CLI all
canonical. Forwarding state machine preserved (sidecar) + queryability preserved (persisted computed columns) — the design's
key insight that central is append-only made pure-9-col central feasible without gutting forwarding.
## Open items to confirm at review
1. **ScadaBridge:** full audit re-architecture (pure 9-col storage) vs the **bounded "deep-at-the-seam"** variant
(canonical record at the seam/reporting boundary; keep typed storage columns + state machine). Strongly
recommend the bounded variant.
2. **MxGateway:** retire `api_key_audit` (canonical store is sole) vs keep it coexisting.
3. **OtOpcUa:** confirm leaving the SP path bespoke (structured path is dormant; canonicalization is forward-looking
prep) is acceptable, and the `ClusterAudit` fix approach (OR-predicate vs populate `ClusterId`).
4. **Sequencing:** OtOpcUa (2.1→2.2) and MxGateway (2.3) are independent + tractable; ScadaBridge (2.5) is the
gating risk — do it last, and as staged reviewed sub-commits regardless of variant.
@@ -0,0 +1,347 @@
# Auth + Audit Normalization Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Publish `ZB.MOM.WW.Auth` (4 pkgs) + `ZB.MOM.WW.Audit` (1 pkg) to the Gitea feed and adopt both across OtOpcUa, MxAccessGateway, and ScadaBridge, ending with every audit emit site carrying the Auth-resolved principal as `AuditEvent.Actor`.
**Architecture:** Library-major waterfall — Phase 0 publish/feed-map → Phase 1 full Auth adoption (auth GAPS #1#8) → Phase 2 full Audit adoption (audit GAPS #1#3,#5,#6) → Phase 3 wire `Actor` from the principal. Behaviour-preserving cutover except two accepted changes (ScadaBridge token format, canonical-roles collapse). One feature branch per repo per library phase; local-only delivery (no `git push`).
**Tech Stack:** .NET 10, NuGet (Gitea feed + central package management), Akka.NET (OtOpcUa/ScadaBridge), EF Core + SQL Server (OtOpcUa) / SQLite (MxGateway, ScadaBridge site), Blazor admin UIs, gRPC (gateway), LDAP/GLAuth, peppered HMAC API keys, xUnit.
**Design doc:** [`2026-06-02-auth-audit-normalization-design.md`](2026-06-02-auth-audit-normalization-design.md)
**Fidelity note:** Phase 0 tasks are command-exact and executable as written. Phase 13 cutover tasks name exact files-to-edit and acceptance criteria but their per-step diffs are elaborated **just-in-time** by the per-phase "explore + elaborate" gate task (the implementer reads the named source first) — these repos' auth source has not been opened during planning, only the normalized `components/*/current-state/` docs. Audit (Phase 2) tasks cite the exact paths/lines those docs provide.
**Prerequisite the executor must supply:** Phase 0 push needs `GITEA_NUGET_KEY` (Gitea token with `package:write`). The agent cannot mint this — the user exports it, or runs the push step via `!`.
---
## PHASE 0 — Publish & feed-map (executable now)
Branch: work on `docs/auth-audit-normalization` (current) or a fresh `chore/publish-auth-audit`. The library packs happen in `scadaproj`; the feed-map edits happen in the three sibling repos (each on its own `feat/adopt-zb-auth` branch — created here, reused in Phase 1).
### Task 0.1: Add a push script for ZB.MOM.WW.Audit
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** none (blocks 0.3)
**Files:**
- Create: `ZB.MOM.WW.Audit/build/push.sh`
**Step 1: Create the script** (mirror `ZB.MOM.WW.Auth/build/push.sh`)
```bash
#!/usr/bin/env bash
# push.sh — pack and push the ZB.MOM.WW.Audit NuGet package to the Gitea feed.
#
# Required environment variables:
# GITEA_NUGET_SOURCE — full URL of the Gitea NuGet feed
# GITEA_NUGET_KEY — Gitea access token with package:write permission
set -euo pipefail
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
dotnet pack -c Release -o ./artifacts
dotnet nuget push "./artifacts/*.nupkg" \
--source "$GITEA_NUGET_SOURCE" \
--api-key "$GITEA_NUGET_KEY" \
--skip-duplicate
```
**Step 2:** `chmod +x ZB.MOM.WW.Audit/build/push.sh`
**Step 3: Commit**
```bash
git add ZB.MOM.WW.Audit/build/push.sh && git commit -m "build(audit): add Gitea push.sh"
```
### Task 0.2: Build + test both libraries green before publishing
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 0.1
**Files:** none (verification only)
**Step 1:** `cd ZB.MOM.WW.Auth && dotnet test` — expect all 172 pass.
**Step 2:** `cd ZB.MOM.WW.Audit && dotnet test` — expect all 19 pass.
**Acceptance:** both suites green. If either fails, STOP — do not publish a red library.
### Task 0.3: Pack + push both libraries to the Gitea feed
**Classification:** standard
**Estimated implement time:** ~4 min (+ network)
**Parallelizable with:** none (blocked by 0.1, 0.2)
**Files:** none (publishes artifacts)
**Step 1: Export credentials** (user-supplied token)
```bash
export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
export GITEA_NUGET_KEY="<gitea token with package:write>"
```
**Step 2:** `cd ZB.MOM.WW.Auth && ./build/push.sh`
**Step 3:** `cd ZB.MOM.WW.Audit && ./build/push.sh`
**Step 4: Verify all 5 resolve (HTTP 200)**
```bash
for p in zb.mom.ww.auth.abstractions zb.mom.ww.auth.ldap zb.mom.ww.auth.apikeys \
zb.mom.ww.auth.aspnetcore zb.mom.ww.audit; do
printf '%s -> ' "$p"
curl -s -o /dev/null -w "%{http_code}\n" \
"https://gitea.dohertylan.com/api/packages/dohertj2/nuget/registration/$p/index.json"
done
```
**Acceptance:** all five print `200` (currently all `404`).
### Task 0.4: Feed-map + restore OtOpcUa
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 0.5, 0.6 (different repos)
**Files:**
- Modify: `~/Desktop/OtOpcUa/NuGet.config` (add patterns under `dohertj2-gitea`)
- Modify: `~/Desktop/OtOpcUa/Directory.Packages.props` (add `PackageVersion` entries)
**Step 1:** create branch `feat/adopt-zb-auth` in OtOpcUa.
**Step 2:** under the `dohertj2-gitea` `packageSource`, add:
```xml
<package pattern="ZB.MOM.WW.Auth" />
<package pattern="ZB.MOM.WW.Auth.*" />
<package pattern="ZB.MOM.WW.Audit" />
```
**Step 3:** in `Directory.Packages.props` add (version 0.1.0): `ZB.MOM.WW.Auth.Abstractions`, `ZB.MOM.WW.Auth.Ldap`, `ZB.MOM.WW.Auth.AspNetCore`, `ZB.MOM.WW.Audit`. (No `ZB.MOM.WW.Auth.ApiKeys` — OtOpcUa uses OPC UA transport security.)
**Step 4:** `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` — expect success, the new packages download from gitea.
**Step 5: Commit** `build: add ZB.MOM.WW.Auth/Audit feed mapping + version pins`.
**Acceptance:** restore succeeds; `obj/project.assets.json` lists the new packages from the gitea source.
### Task 0.5: Feed-map + restore MxAccessGateway
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 0.4, 0.6
**Files:**
- Modify: `~/Desktop/MxAccessGateway/nuget.config`
- Modify: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj` (inline `Version=` style — no CPM)
**Step 1:** branch `feat/adopt-zb-auth` in MxAccessGateway.
**Step 2:** add the same three `<package pattern>` lines under `dohertj2-gitea`.
**Step 3:** `dotnet restore src/MxGateway.sln` (PackageReferences added in Phase 1; this step only proves the feed resolves — optionally add a throwaway reference and remove, or defer restore-proof to Phase 1's first add).
**Step 4: Commit** `build: add ZB.MOM.WW.Auth/Audit feed mapping`.
**Acceptance:** `nuget.config` maps the new patterns; restore of an added Auth package succeeds.
### Task 0.6: Feed-map + restore ScadaBridge
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 0.4, 0.5
**Files:**
- Modify: `~/Desktop/ScadaBridge/nuget.config`
- Modify: `~/Desktop/ScadaBridge/Directory.Packages.props`
**Step 1:** branch `feat/adopt-zb-auth` in ScadaBridge.
**Step 2:** add the three `<package pattern>` lines under `dohertj2-gitea`.
**Step 3:** add `PackageVersion` entries @ 0.1.0 for all 4 Auth packages + `ZB.MOM.WW.Audit`.
**Step 4:** `dotnet restore ZB.MOM.WW.ScadaBridge.slnx`.
**Step 5: Commit** `build: add ZB.MOM.WW.Auth/Audit feed mapping + version pins`.
**Acceptance:** restore succeeds.
> **Phase 0 exit gate:** all 5 packages HTTP 200; all 3 repos restore green with the new feed mappings. Only then start Phase 1.
---
## PHASE 1 — Auth adoption (auth GAPS #1#8) *[HIGH-RISK PHASE]*
Order within the phase (per `components/auth/GAPS.md` sequencing): **#3 seam → #1 Ldap + #2 ApiKeys → #4 config + #5 claims/cookies → #6 base DN → #8 canonical roles.** Every cutover is gated by parity tests before merge.
### Task 1.0: Explore auth source + elaborate Phase 1 steps *(GATE — do first)*
**Classification:** standard
**Estimated implement time:** ~5 min (read-only)
**Parallelizable with:** none (blocks all 1.x)
**Files (read-only):**
- `components/auth/current-state/{otopcua,mxaccessgw,scadabridge}/CURRENT-STATE.md`
- `components/auth/spec/SPEC.md`, `components/auth/spec/CANONICAL-ROLES.md`, `components/auth/shared-contract/ZB.MOM.WW.Auth.md`
- `ZB.MOM.WW.Auth/src/**` (the public surface being adopted)
- Each repo's LDAP auth service, API-key pipeline, role mapper, and auth DI wiring (paths surfaced by the current-state docs).
**Action:** read the above; for each task below fill in the concrete diff, exact file paths, and the parity-test assertions. Append the elaborated steps to this plan section (or a `…-phase1.md` companion). **No code changes in this task.** This gate exists because the per-repo auth source was not opened during planning.
### Task 1.1: `IGroupRoleMapper<TRole>` seam — config + DB mappers (GAPS #3, all 3 repos)
**Classification:** standard
**Estimated implement time:** ~5 min/repo (split per repo if needed)
**Parallelizable with:** 1.2 within a repo only after the seam type is referenced
**Files:** per-repo role-mapping call sites (config-backed for OtOpcUa + MxGateway; DB-backed `LdapGroupMapping` for ScadaBridge) — exact paths from Task 1.0.
**Steps:** TDD — write a mapper test asserting current group→role outputs are preserved → wire the app to the library's `IGroupRoleMapper<TRole>` (config mapper for OtOpcUa/gw, DB/delegate mapper for SB) → green → commit. **Acceptance:** existing role-resolution behaviour byte-identical; #3 done (cheap, unblocks the rest).
### Task 1.2: Adopt `ZB.MOM.WW.Auth.Ldap` — cutover (GAPS #1, all 3 repos)
**Classification:** high-risk (security; LDAP)
**Estimated implement time:** split per repo (~5 min each)
**Parallelizable with:** 1.3 (different repos) — but within a repo, serial after 1.1
**Files:** each repo's LDAP authentication service + DI (ScadaBridge is the donor baseline; OtOpcUa/gw cut over to it). For OtOpcUa also fix the open `LdapAuthService` `Enabled`/double-singleton wiring (repo memory).
**Steps (per repo):** write parity tests reproducing current authn decisions (bind-then-search, fail-closed-on-group-lookup, RFC-4514 + filter escaping, username trim, service-account-bind distinction) → run red against the library path → replace bespoke LDAP with `Auth.Ldap` → green → commit. **Acceptance:** parity tests green; bespoke LDAP code removed/delegated; OtOpcUa singleton bug fixed.
### Task 1.3: Adopt `ZB.MOM.WW.Auth.ApiKeys` — cutover (GAPS #2; MxGateway then ScadaBridge)
**Classification:** high-risk (security; API keys)
**Estimated implement time:** ~5 min/repo
**Parallelizable with:** 1.2 (different files) — MxGateway first (source), then ScadaBridge
**Files:** MxGateway `Security/Authentication/` API-key verifier/store DI; ScadaBridge Inbound API `X-API-Key` path.
**Steps:** parity tests (peppered HMAC-SHA256, constant-time compare, scope/constraint enforcement) → cutover to `Auth.ApiKeys` → green → commit. **ScadaBridge behaviour change (accepted):** raw `X-API-Key` → structured `<prefix>_<id>_<secret>`; add an **interop check** that an inbound client using the new token format authenticates and the old format is rejected. **Acceptance:** parity + interop green; gateway is the proven source before SB cuts over.
### Task 1.4: Config schema migration (GAPS #4 / A1A2, all 3 repos)
**Classification:** standard
**Estimated implement time:** ~4 min/repo
**Parallelizable with:** bundled with 1.2 per the GAPS note ("mechanical; do with #1")
**Files:** OtOpcUa + MxGateway: `UseTls``Transport` enum binding + appsettings. ScadaBridge: flat `Security:Ldap*`→nested section; rename `LdapUserIdAttribute``UserNameAttribute`, `LdapGroupAttribute``GroupAttribute` (+ appsettings + any validators).
**Steps:** update options class + binding + appsettings + (ScadaBridge) `ConfigPreflight`/validator messages → run config-validation tests → commit. **Acceptance:** apps bind the new schema; no behaviour change beyond key names/enum.
### Task 1.5: `ZB.MOM.WW.Auth.AspNetCore` claims/cookie conventions (GAPS #5, all 3 UIs)
**Classification:** standard
**Estimated implement time:** ~4 min/repo
**Parallelizable with:** 1.4
**Files:** each UI's cookie/claims wiring (OtOpcUa Blazor Admin control-plane; MxGateway `MxGatewayDashboard`; ScadaBridge `ZB.MOM.WW.ScadaBridge.Auth`). Keep each cookie **name**; share canonical claim types + attributes.
**Steps:** adopt the shared claim-type constants + cookie attribute defaults → auth-flow test (login sets canonical claims) → commit. **Acceptance:** each app keeps its cookie name but emits canonical claims.
### Task 1.6: Unify dev GLAuth base DN (GAPS #6, all 3 + fixtures)
**Classification:** small (dev-only)
**Estimated implement time:** ~3 min
**Parallelizable with:** 1.5
**Files:** dev appsettings + LDAP/GLAuth fixtures/infra in each repo. Pick one shared base DN (open decision A3 — resolve in Task 1.0).
**Acceptance:** dev fixtures + all 3 apps share one base DN; dev login still works.
### Task 1.7: Canonical roles — `canonical → native` expansion (GAPS #8, all 3 repos)
**Classification:** high-risk (security policy)
**Estimated implement time:** ~5 min/repo
**Parallelizable with:** none (after 1.1)
**Files:** each repo's role-enforcement mapping. **ScadaBridge accepted collapse:** `AuditReadOnly`→Viewer, `Audit`→Administrator (auditor/admin SoD removed). OtOpcUa: publish ⊂ `FleetAdmin` (no first-class `Deployer`). MxGateway: assign applicable subset (no `Designer`/`Deployer`).
**Steps:** map each canonical role to native enforcement; test that each LDAP group still authorizes its expected actions; document the SoD change → commit. **Acceptance:** canonical six standardized org-wide; per-project native enforcement unchanged except the documented ScadaBridge collapse.
> **Phase 1 exit gate:** all 3 repos consume `ZB.MOM.WW.Auth.*` from the feed; bespoke LDAP/ApiKey/role code removed or delegated; existing auth tests + new parity tests green per repo; SB token-format interop check green. Merge each `feat/adopt-zb-auth` to the repo's local default branch (no push).
---
## PHASE 2 — Audit adoption (audit GAPS #1#3, #5, #6)
> ⚠️ **RE-SCOPED 2026-06-02 — the task specs below are SUPERSEDED.** The Task 2.0 gate (verified against
> live code) found these specs materially wrong: MxGateway's audit files moved into the shared library
> (Phase 1), OtOpcUa's structured audit path is dormant (zero emit sites), and the ScadaBridge
> "outright rename" is structurally impossible (its filter is typed to its own 24-field record, not the
> library's 9-field one). The user chose **DEEP adopt (canonical record)** + **pause for review**. The
> corrected, gate-grounded deep design is in
> [`2026-06-02-auth-audit-normalization-phase2-deep.md`](2026-06-02-auth-audit-normalization-phase2-deep.md)
> — **implementation is PAUSED pending user review of that doc (esp. the ScadaBridge audit-subsystem
> re-architecture cost).** The original specs below are kept for historical context only.
Branch `feat/adopt-zb-audit` per repo. Behaviour-preserving except the OtOpcUa `Outcome` column + `ClusterId` visibility fix. Concrete paths below come from `components/audit/current-state/*`.
### Task 2.0: Explore audit source + confirm elaboration *(GATE — light, paths already known)*
**Classification:** trivial
**Estimated implement time:** ~3 min (read-only)
**Parallelizable with:** none (blocks 2.x)
**Files (read-only):** the exact files cited in the tasks below (OtOpcUa `AuditWriterActor.cs`, `Commons/Messages/Audit/AuditEvent.cs`, `ConfigAuditLog.cs`, `OtOpcUaConfigDbContext.cs`, `ClusterAudit.razor`; MxGateway `IApiKeyAuditStore.cs`, `SqliteApiKeyAuditStore.cs`, `ApiKeyAuditEntry.cs`, `ConstraintEnforcer.cs`, the 3 producers; ScadaBridge `IAuditPayloadFilter.cs`, `IAuditWriter.cs`, `AuditEvent.cs`, the 4 enums). Confirm line refs still hold; adjust if drifted.
### Task 2.1: OtOpcUa — canonical record + `AuditWriterActor : IAuditWriter` + `Outcome` (GAPS #1)
**Classification:** high-risk (actor model + data contract)
**Estimated implement time:** split (record swap ~5 min; actor seam ~5 min; Outcome derivation ~5 min)
**Parallelizable with:** 2.3, 2.5 (different repos)
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs` (replace with canonical record usage; bridge `NodeId`/`CorrelationId` value-types at construction)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs` (implement `IAuditWriter`; map at `:75-84`)
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs`
**Steps:** TDD — extend actor tests to assert `Outcome` derivation (`OpcUaAccessDenied`/`CrossClusterNamespaceAttempt`→Denied, config verbs→Success) and the canonical record mapping → red → swap record + implement seam + derive `Outcome` at emit sites → keep 500/5s batching + two-layer dedup → green → commit. **Acceptance:** existing tests + new `Outcome` tests green; transport/dedup unchanged.
### Task 2.2: OtOpcUa — `Outcome` column migration + `ClusterId` visibility fix (GAPS #1 storage, #5)
**Classification:** high-risk (EF migration + UI query)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (after 2.1)
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs` (add nullable `Outcome`)
- Modify: `.../OtOpcUaConfigDbContext.cs` (mapping ~`:429-463`)
- Create: `Migrations/<ts>_AddConfigAuditLogOutcome.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor:78` (so structured actor rows — which set `NodeId` not `ClusterId` — are discoverable)
**Steps:** add column + migration → `dotnet ef migrations add` + apply on a test DB → adjust the query so structured rows appear under a cluster → commit. Leave the SP path bespoke (documented). **Acceptance:** migration applies forward; structured `AuditEvent` rows now visible in `ClusterAudit.razor`.
### Task 2.3: MxGateway — `IApiKeyAuditStore``IAuditWriter` adapter (GAPS #2, #6)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 2.1, 2.5
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/``IApiKeyAuditStore.cs`, `SqliteApiKeyAuditStore.cs`, `ApiKeyAuditEntry.cs`, `AuthStoreServiceCollectionExtensions.cs:23`, and the 3 producers (`ApiKeyAdminCliRunner`, `DashboardApiKeyManagementService`, `ConstraintEnforcer.cs:117`)
- Test: gateway audit tests (`SqliteAuthStoreTests`, `ApiKeyAdminCliRunnerTests`)
**Steps:** map to canonical `AuditEvent` — generate `EventId`; `KeyId→Actor` with `"system"`/`"cli"` fallback; `EventType→Action`; `CreatedUtc→OccurredAtUtc`; `RemoteAddress→SourceNode`; `constraint-denied→Outcome.Denied` else `Success`; `Category="ApiKey"`; `Details→DetailsJson` **wrapped as a JSON object**; add `CorrelationId` capture + structured `Target` (#6). **Wrap `AppendAsync` so it never throws** (best-effort contract). Producers keep call sites; only the injected type changes. → tests green → commit. **Acceptance:** writes produce canonical events; writer never propagates; tests green.
### Task 2.5: ScadaBridge — rename `IAuditPayloadFilter``IAuditRedactor` + adopt `AuditOutcome` (GAPS #3)
**Classification:** high-risk (HIGH blast radius rename across site/central/wiring)
**Estimated implement time:** ~5 min (compiler-driven)
**Parallelizable with:** 2.1, 2.3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/IAuditPayloadFilter.cs` → adopt `ZB.MOM.WW.Audit.IAuditRedactor` (outright rename; `DefaultAuditPayloadFilter`/`SafeDefaultAuditPayloadFilter` implement it unchanged)
- Modify: all references across `AuditLog/Site`, `AuditLog/Central`, wiring, `Commons`
- Adopt canonical `AuditOutcome` enum; confirm `IAuditWriter` signature is byte-identical (keep the bespoke ~25-field record as storage shape — option (a))
**Steps:** outright rename (let the compiler enumerate sites) → adopt `AuditOutcome` and the `Status→Outcome` projection (`Delivered`→Success; `Failed`/`Parked`/`Discarded`→Failure; `InboundAuthFailure`→Denied) for cross-project reporting → build + full audit test suite green → commit. **Acceptance:** compiles clean; no transport/storage/CLI/UI behaviour change; enum + interface names canonical.
> **Phase 2 exit gate:** all 3 repos consume `ZB.MOM.WW.Audit`; seams/record/enum canonical; existing audit suites green; OtOpcUa `Outcome` migration applies; ScadaBridge rename clean. Merge each `feat/adopt-zb-audit` locally (no push).
---
## PHASE 3 — Wire `Actor` from the Auth principal (audit GAPS #4)
### Task 3.1: Introduce `IAuditActorAccessor` seam
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocks 3.23.4)
**Files:** a small accessor per app (HTTP impl reads `HttpContext.User`; non-HTTP returns a threaded/fallback principal). Exact location decided in Task 1.0/3.1 from the now-adopted `Auth.AspNetCore` principal plumbing.
**Steps:** define the interface + an HTTP-backed impl + a fallback impl → unit test both → commit. **Acceptance:** accessor returns the Auth principal on authenticated paths, a fallback otherwise.
### Task 3.2 / 3.3 / 3.4: Wire emit sites — OtOpcUa / MxGateway / ScadaBridge
**Classification:** standard (each)
**Estimated implement time:** ~4 min each
**Parallelizable with:** each other (different repos), after 3.1
**Files:** each repo's audit emit sites (OtOpcUa config-write/authz emitters; MxGateway 3 producers — keep `"system"`/`"cli"` for keyless CLI; ScadaBridge `ManagementActor`/inbound boundary).
**Steps:** inject `IAuditActorAccessor`; set `AuditEvent.Actor = accessor.CurrentPrincipal` at each emit site → test `Actor == authenticated principal` on authenticated paths, fallback retained otherwise → commit. **Acceptance:** every authenticated emit carries the real Auth principal; keyless/system paths retain explicit fallbacks.
> **Program exit gate:** `Audit.Actor == Auth principal` end-to-end across all 3 repos; all suites green; everything on local default branches (no push). Update `components/auth/GAPS.md` and `components/audit/GAPS.md` to mark the adopted items done, and refresh the relevant `CLAUDE.md` status rows.
---
## Risk gates (cross-cutting)
- **Never publish a red library** (Task 0.2 gates 0.3). If a parity gap forces a lib fix, bump `0.1.0``0.1.1` and re-publish; don't edit a published version.
- **Phase 1 parity tests** must be green before any auth cutover merges — this is the security gate.
- **A green build in one repo does not prove interop.** The ScadaBridge token-format change (Task 1.3) is the one cross-boundary contract change and needs the explicit interop check.
- **Waterfall enforced by deps:** Phase 1 fully lands before Phase 2; Phase 3 after both.
@@ -0,0 +1,43 @@
{
"planPath": "docs/plans/2026-06-02-auth-audit-normalization.md",
"designPath": "docs/plans/2026-06-02-auth-audit-normalization-design.md",
"tasks": [
{"id": 7, "subject": "Phase 0 umbrella — publish + feed-map", "status": "completed", "blockedBy": [11, 12, 13, 14, 15, 16]},
{"id": 8, "subject": "Phase 1 umbrella — adopt ZB.MOM.WW.Auth — COMPLETE (all of 1.0-1.7 across 3 repos, reviewed, local-only)", "status": "completed", "blockedBy": [7, 17, 18, 19, 20, 21, 22, 23, 24]},
{"id": 9, "subject": "Phase 2 umbrella — adopt ZB.MOM.WW.Audit — COMPLETE (OtOpcUa 2.1/2.2, MxGateway 2.3, ScadaBridge 2.5 full re-arch C1-C7; all reviewed, local-only)", "status": "completed", "blockedBy": [7, 8, 25, 26, 27, 28, 29]},
{"id": 10, "subject": "Phase 3 umbrella — wire Actor from Auth principal — COMPLETE (IAuditActorAccessor per app + emit-site wiring; all reviewed, local-only)", "status": "completed", "blockedBy": [8, 9, 30, 31]},
{"id": 11, "subject": "Task 0.1: Add push.sh for ZB.MOM.WW.Audit", "status": "completed", "blockedBy": []},
{"id": 12, "subject": "Task 0.2: Build+test both libs green", "status": "completed", "blockedBy": []},
{"id": 13, "subject": "Task 0.3: Pack+push both libs; verify HTTP 200", "status": "completed", "blockedBy": [11, 12]},
{"id": 14, "subject": "Task 0.4: Feed-map + restore OtOpcUa", "status": "completed", "blockedBy": [13]},
{"id": 15, "subject": "Task 0.5: Feed-map MxAccessGateway", "status": "completed", "blockedBy": [13]},
{"id": 16, "subject": "Task 0.6: Feed-map + restore ScadaBridge", "status": "completed", "blockedBy": [13]},
{"id": 17, "subject": "Task 1.0: GATE explore auth source + elaborate", "status": "completed", "blockedBy": [14, 15, 16]},
{"id": 18, "subject": "Task 1.1: IGroupRoleMapper seam (#3)", "status": "completed", "blockedBy": [17]},
{"id": 19, "subject": "Task 1.2: Adopt Auth.Ldap cutover (#1) [high-risk]", "status": "completed", "blockedBy": [18]},
{"id": 20, "subject": "Task 1.3: Adopt Auth.ApiKeys (#2) [high-risk] — COMPLETE (MxGw donor + ScadaBridge re-arch C1-C5)", "status": "completed", "blockedBy": [18]},
{"id": 21, "subject": "Task 1.4: Config schema migration A1/A2 (#4)", "status": "completed", "blockedBy": [17]},
{"id": 22, "subject": "Task 1.5: AspNetCore claims/cookies (#5) — DONE all 3 (OtOpcUa 83856b7+d0777ee, MxGw 7e1af37, SB full-canonical a0938f7+c185a56)", "status": "completed", "blockedBy": [17]},
{"id": 23, "subject": "Task 1.6: Unify dev base DN (#6) — DONE all 3 to dc=zb,dc=local (OtOpcUa 8ba289f, MxGw 9572045, SB 6ae6051)", "status": "completed", "blockedBy": [17]},
{"id": 24, "subject": "Task 1.7: Canonical roles native expansion (#8) [high-risk] — DONE all 3, full-value canonical (MxGw 04bce3ff, OtOpcUa c1619d9 +DB-mig, SB b104760+4118452 +DB-mig +SoD collapse)", "status": "completed", "blockedBy": [18]},
{"id": 25, "subject": "Task 2.0: GATE confirm audit source refs — DONE; found plan specs materially off → DEEP re-scope in -phase2-deep.md; PAUSED for user review before 2.1/2.2/2.3/2.5", "status": "completed", "blockedBy": [8]},
{"id": 26, "subject": "Task 2.1: OtOpcUa canonical record + IAuditWriter + Outcome (#1) [high-risk] — DONE 933dd1a (spec+code reviewed)", "status": "completed", "blockedBy": [25]},
{"id": 27, "subject": "Task 2.2: OtOpcUa Outcome migration + ClusterId fix (#1,#5) [high-risk] — DONE b7f5e88 (spec+code reviewed)", "status": "completed", "blockedBy": [26]},
{"id": 28, "subject": "Task 2.3: MxGateway store→IAuditWriter adapter (#2,#6) [re-scoped high-risk] — DONE a5944bb+7ea8358 (canonical SQLite store+adapter; spec+code reviewed)", "status": "completed", "blockedBy": [25]},
{"id": 29, "subject": "Task 2.5: ScadaBridge audit DEEP full-rearch to 9-col canonical (#3) [high-risk] — DONE C1-C7 (3d77dc0,adfb4d3/5aaf9e2,db707bb/c27b2c3,946d3e2/1737d15,68a6bd1,C6-subsumed,635461c/bc0e5bf); all spec+code reviewed, MSSQL-verified", "status": "completed", "blockedBy": [25]},
{"id": 30, "subject": "Task 3.1: IAuditActorAccessor seam (per-app HTTP accessor) — DONE (OtOpcUa 075c0e6, MxGw 0859d47, SB b3de840)", "status": "completed", "blockedBy": [9]},
{"id": 31, "subject": "Task 3.2-3.4: Wire emit sites to Auth principal (#4) — DONE (MxGw dashboard Actor=operator/Target=keyId; SB inbound Actor from principal w/ auth-fail-null; OtOpcUa seam forward-looking) — reviewed", "status": "completed", "blockedBy": [30]},
{"id": 32, "subject": "Task 1.3-L: Extend Auth.ApiKeys admin store (SetScopes/SetEnabled) -> lib 0.1.3 (PUBLISHED)", "status": "completed", "blockedBy": []},
{"id": 33, "subject": "Task 1.3-C1: ScadaBridge re-pin 0.1.3 + IInboundApiKeyAdmin seam (additive) + baseline reds fixed", "status": "completed", "blockedBy": [32]},
{"id": 34, "subject": "Task 1.3-C2: ManagementActor + CLI + Commons messages onto seam", "status": "completed", "blockedBy": [33]},
{"id": 35, "subject": "Task 1.3-C3: CentralUI pages onto seam (string keyId + scopes)", "status": "completed", "blockedBy": [33]},
{"id": 36, "subject": "Task 1.3-C4: TransportExport exclude API keys (methods-only)", "status": "completed", "blockedBy": [33, 35]},
{"id": 37, "subject": "Task 1.3-C5 (=E): retire SQL Server ApiKey entity + EF migration + runbook", "status": "completed", "blockedBy": [34, 35, 36]}
],
"lastUpdated": "2026-06-02 — PROGRAM COMPLETE (Phases 0-3 done across 3 repos: Auth + Audit normalized, Actor wired from principal). All local-only on feat/adopt-zb-auth + feat/adopt-zb-audit; NOTHING pushed/merged. Remaining = exit-gate doc updates + user merge/push decision."
}
@@ -0,0 +1,78 @@
# ScadaBridge audit re-architecture (Task 2.5, DEEP full 9-col) — decomposition
Companion to `2026-06-02-auth-audit-normalization-phase2-deep.md`. User chose **Full re-arch (pure 9-col storage)**
for ScadaBridge audit. Architect design pass (read-only, verified on `feat/adopt-zb-audit`) produced this. The full
audit record becomes the library 9-field `ZB.MOM.WW.Audit.AuditEvent`; ~15 domain fields relocate into `DetailsJson`;
ScadaBridge consumes the library `IAuditWriter`/`IAuditRedactor`/`AuditOutcome`. This is the program's largest task.
## Key resolutions (from the design)
- **Forwarding state machine (the crux) → resolved cleanly.** It lives **only in site SQLite**; the central MS SQL
`AuditLog` table is **append-only** (DENY UPDATE/DELETE; central rows leave `ForwardState` null; reconciliation is
pure idempotent-insert with in-memory cursors), and the gRPC `AuditEventDtoMapper` **already** drops
`ForwardState`/`IngestedAtUtc` on the wire. So **central needs NO forwarding columns** (pure 9-col). On the **site**,
add a **sidecar `audit_forward_state` table** keyed by `EventId` (`ForwardState`, `OccurredAtUtc`, precomputed
`IsCachedKind`, optional `AttemptCount`/`LastAttemptUtc`) — `MarkForwarded`/`MarkReconciled` UPDATE the sidecar;
`ReadPending*` JOIN it; the canonical `audit_event` table is write-once. Precomputing `IsCachedKind` keeps the drain
hot path off JSON parsing (strictly faster than today's `Kind NOT IN(...)`).
- **Central storage migration → new table + copy** (in-place collapse infeasible: partition-aligned indexes +
`SwitchOutPartitionAsync` hard-codes a byte-identical staging column list). New 10-col table on the SAME
`ps_AuditLog_Month(OccurredAtUtc)` scheme; per-partition data copy projecting old typed columns into `DetailsJson`
(`FOR JSON PATH`); rename + role re-grant (append-only preserved). Partitioning preserved (`OccurredAtUtc` stays).
- **Reporting queryability → persisted computed columns for hot filters.** `Category`(=Channel) + canonical
`Outcome`/`Target`/`Actor`/`SourceNode`/`CorrelationId` cover most filters directly. Add **PERSISTED computed columns**
`Kind`/`Status`/`SourceSiteId`/`ExecutionId`/`ParentExecutionId` (`JSON_VALUE(DetailsJson,'$.x')`) + partition-aligned
indexes so the existing index semantics + the `GetExecutionTreeAsync` recursive CTE survive without a JSON perf cliff.
- **Redactor → `ScadaBridgeAuditRedactor : IAuditRedactor`** on the canonical record: parse `DetailsJson` once, redact +
byte-safe-truncate `requestSummary`/`responseSummary`/`errorDetail`/`extra` in the JSON tree, cap on canonical
`Category`/`Outcome` (replacing the typed `Channel`/`Status` reads), set `payloadTruncated`, re-serialize. Add a
fast-path that skips JSON parse when nothing to redact. `SafeDefault``SafeDefaultAuditRedactor`. Re-baseline the
perf hot-path budgets (JSON parse/rewrite is ~24× the typed-field path).
- **Canonical field mapping:** `Action = "{Channel}.{Kind}"`; `Category = Channel`; `Target/SourceNode/CorrelationId/
Actor/OccurredAtUtc` direct (DateTime→DateTimeOffset UTC). **`Outcome`:** `Kind==InboundAuthFailure`→`Denied` (checked
first); `Status==Delivered``Success`; `Status∈{Failed,Parked,Discarded}``Failure`; in-flight/`Skipped``Success`.
- **`DetailsJson` schema (camelCase, stable):** channel, kind, status, executionId, parentExecutionId, sourceSiteId,
sourceInstanceId, sourceScript, httpStatus, durationMs, errorMessage, errorDetail, requestSummary, responseSummary,
payloadTruncated, extra, ingestedAtUtc. **One shared `AuditDetailsCodec` (Commons) with deterministic options is
MANDATORY** — the canonical record uses value-equality + consumers dedup on it, so key-order/whitespace drift would
break dedup. (`forwardState` is NOT in DetailsJson — it's site-sidecar only.)
- **Commons takes the `ZB.MOM.WW.Audit` package ref** (the record lives in Commons; the package is a leaf canonical-types
pkg, only dep `Microsoft.Extensions.DependencyInjection.Abstractions`). Acceptable.
- **gRPC proto kept UNCHANGED** — the wire `AuditEventDto` stays 24-field internally; `AuditEventDtoMapper` projects
to/from `DetailsJson`. Avoids a proto/codegen rev + a site/central version-skew handshake. (A proto collapse is a
separate later task.)
## Staged decomposition (C1C7)
| Stage | Scope | Green? | Class | Risk |
|---|---|---|---|---|
| **C1** | Commons: add `ZB.MOM.WW.Audit` ref; new pure types `AuditDetails` record + `AuditDetailsCodec` (deterministic) + `Status/Kind→AuditOutcome` projection + `Action`/`Category` builders. No existing type changes. | yes | small | trivial |
| **C2** | `ScadaBridgeAuditRedactor`/`SafeDefaultAuditRedactor : IAuditRedactor` (canonical record, parse/rewrite DetailsJson, fast-path) — additive, old `IAuditPayloadFilter` still wired; unit-tested in isolation. | yes | standard | low |
| **C3** | **ATOMIC CUT — swap the record everywhere.** `Commons.Entities.Audit.AuditEvent``ZB.MOM.WW.Audit.AuditEvent` across ~40 src files + tests: emitters build canonical (domain→DetailsJson via codec); seams (`IAuditWriter`/`ICentralAuditWriter`/`ISiteAuditQueue`/`IAuditLogRepository`/`AuditLogQueryFilter`) re-type; `AuditEventDtoMapper` DTO↔canonical (proto unchanged); switch redactor wiring `IAuditPayloadFilter``IAuditRedactor`. | **boundaries only** | **high-risk** | **HIGHEST** |
| **C4** | Site SQLite two-table forwarding: `SqliteAuditWriter``audit_event` + `audit_forward_state`; retarget `MarkForwarded/MarkReconciled/ReadPending*/GetBacklogStats/MapRow` to JOIN+sidecar; precompute `IsCachedKind`. Telemetry/Reconciliation actors unchanged (seam stable). Site SQLite is ephemeral (7-day) → in-place schema reset, no data migration. | yes | high-risk | HIGH |
| **C5** | **ATOMIC CUT — central migration.** EF `CollapseAuditLogToCanonical`: new 10-col table on the partition scheme + per-partition data copy (old cols→DetailsJson) + persisted computed cols/indexes + rename + role re-grant; update `AuditLogRepository.InsertIfNotExistsAsync` + `SwitchOutPartitionAsync` staging list; regen ModelSnapshot. Maintenance-window; verify row-count + JSON spot-check. | **boundaries only** | **high-risk** | **HIGHEST** |
| **C6** | Reporting/UI/export retarget: `QueryAsync`/`GetKpiSnapshotAsync`/`GetExecutionTreeAsync` predicates→canonical/computed cols; `AuditLogExportService`+`AuditEndpoints` CSV + CentralUI Audit components + CLI parse `DetailsJson` for display. | yes | standard | med |
| **C7** | Tests + perf re-baseline + cleanup: rewrite `PayloadFilterContractTests`/redaction/`HotPathLatencyTests` to canonical+JSON + new budget; delete dead `Commons.Entities.Audit.AuditEvent`, 4 audit enums (or relocate behind codec), `IAuditPayloadFilter`/`Default`/`SafeDefault`, obsolete `AddColumnIfMissing`. | yes | standard | low |
**Atomic cuts:** only C3 (shared record type changes for all callers at once) and C5's data-copy half cannot stay green continuously. All other stages are green at completion.
## Top risks (carry into execution)
1. **C5 partition + `SwitchOutPartitionAsync` + persisted computed columns** — staging table must carry identical computed defs for SWITCH; add a SWITCH round-trip integration test before C5 ships. **Documented fallback:** if too brittle, keep `Kind`/`Status` as 2 real non-canonical columns on the central table (pragmatic, not pure-9-col) — decide at C5 implementation if blocked.
2. **DetailsJson determinism** — single `AuditDetailsCodec` (C1) is load-bearing for value-equality/dedup, not cosmetic.
3. **Redactor perf** — budgets move; add the no-op fast-path + empirically re-baseline in C7.
4. **gRPC** — keep the proto unchanged (mapper-internal projection); do NOT couple a wire change to this storage cut.
5. **`Action=Channel.Kind`** lossiness — mitigated by `Category`(=channel) + persisted computed `Kind`; ScadaBridge-internal filtering uses those, not `Action` parsing.
Delivery: `feat/adopt-zb-audit` (stacked on auth), local-only. Each stage = one implementer + classification review chain; full ScadaBridge suite at C3/C4/C5/C7.
## Stage status (live)
- **✅ C1 DONE** `3d77dc0` (code ✅) — `AuditDetails` + deterministic `AuditDetailsCodec` (pinned byte-exact) + `AuditOutcomeProjector` + `AuditFieldBuilders` + Commons→`ZB.MOM.WW.Audit` ref; 56 tests.
- **✅ C2 DONE** `adfb4d3` + fix `5aaf9e2` (spec ✅, code ✅ after fix) — `ScadaBridgeAuditRedactor`/`SafeDefaultAuditRedactor : IAuditRedactor` on the canonical record; redaction primitives extracted into shared `AuditRedactionPrimitives`/`AuditRegexCache` (old filter delegates, behaviour-preserved); cap-selection reads `d.Status` (faithful to legacy `IsErrorStatus`); fast-path + never-throws; review-fix hardened `OverRedact` to scrub ALL free-text fields + marker alignment + outer-catch never-leak test. 61 redaction + 44 payload + 88 commons-audit green.
- **✅ C3 DONE** `db707bb` + fix `c27b2c3` (spec ✅, code ✅; independently re-verified build 0/0 + AuditLog 241/Communication 201). Atomic record swap across all seams/emitters/gRPC DTO/redactor-wiring (127 files); `ScadaBridgeAuditEventFactory` single emit point; `AuditRowProjection` Decompose/Recompose transitional 24-col shim (lossless round-trip verified); proto unchanged; old `IAuditPayloadFilter` classes deleted (C7 pulled forward). Fix: safe enum-parse fallback in `MapRow`+`FromDto`.
- **✅ C4 DONE** `946d3e2` + fix `1737d15` (spec ✅, code ✅; independently re-verified diff scope = writer+tests only, build 0/0, AuditLog 249/1-preexisting). Site SQLite → `audit_event` (canonical) + `audit_forward_state` sidecar; forwarding marks/reads on the sidecar via JOIN; `IsCachedKind`={CachedSubmit,ApiCallCached,DbWriteCached,CachedResolve} precomputed drain split; old `AuditLog` table dropped (ephemeral reset). Fix: `PRAGMA foreign_keys=ON` + `MarkForwarded` no-demote guard.
- **✅ C5 DONE** `68a6bd1` (spec ✅, code ✅; a LIVE SQL Server was available so the migration + SWITCH were fully exercised — independently re-verified build 0/0 + ConfigurationDatabase 248/248). Central `dbo.AuditLog` collapsed to 10 canonical cols + 6 computed cols (5 PERSISTED + `IngestedAtUtc` non-persisted) on the preserved `ps_AuditLog_Month` scheme; `CollapseAuditLogToCanonical` new-table-and-copy migration (`FOR JSON PATH` projection, byte-verified round-trip; Down = documented one-way); repo writes/reads canonical directly; `SwitchOutPartition` staging matches the computed-col defs; append-only roles re-granted. C3 central shim retired. Forced deviations (all sound): IngestedAtUtc non-persisted, execution-id indexes unfiltered, provider-aware `OnModelCreating` strips JSON_VALUE for SQLite. Deferred to C7: a dedicated migration-projection test + the stale `CreatesFiveNamedIndexes` test name.
- **✅ C6 SUBSUMED** (no commit) — reporting/UI/export/CLI retarget was already completed by the C3 record-swap (`AuditEventView`/`AuditExportRow` shims decode every domain field from `DetailsJson`) + the C5 repo-query retarget. Read-only explorer verdict: all consumer surfaces canonical-complete; the only flagged items (ExecutionId/ParentExecutionId not in CSV; SourceNodes not parsed in export `ParseFilter`) are PRE-rearch omissions, not regressions. CentralUI 595/595, ManagementService 125/125 confirm.
- **✅ C7 DONE** `635461c` + doc-fix `bc0e5bf` (review ✅; independently re-verified build 0/0, PerformanceTests 10/10, ConfigurationDatabase 251/251 incl. the 3 new migration-projection tests PASSING on live MSSQL, zero dead crefs). Perf hot-path re-baselined (canonical JSON redactor measured ~14µs/2µs — faster than the old typed walk; budgets 200/30/5µs + fast-path `Assert.Same`); `CollapseAuditLogToCanonicalMigrationTests` (seed→migrate→assert Action/Category/Outcome/Actor-null/DetailsJson-round-trip + 5 persisted computed cols); index test → `CreatesNineNamedIndexes`; 26 dead-`<see cref>` across 13 files cleaned; doc-fix corrected the "six persisted" wording (5 persisted + IngestedAtUtc non-persisted).
## ✅ TASK 2.5 COMPLETE — ScadaBridge audit FULL re-architecture to pure 9-col canonical (2026-06-02)
All of C1C7 done, each spec+code reviewed, on `feat/adopt-zb-audit` (local-only, never pushed). ScadaBridge's audit subsystem now: the canonical `ZB.MOM.WW.Audit.AuditEvent` record everywhere (domain fields in `DetailsJson` via the deterministic `AuditDetailsCodec`); the library `IAuditRedactor`/`AuditOutcome` consumed; site SQLite = `audit_event` (canonical) + `audit_forward_state` sidecar (forwarding decoupled, `IsCachedKind` drain split); central `dbo.AuditLog` collapsed to 10 canonical cols + persisted computed cols on the preserved partition scheme (`CollapseAuditLogToCanonical` migration, MSSQL-verified); UI/export/CLI canonical-complete via `AuditEventView`/`AuditExportRow`. The gRPC proto was intentionally left unchanged (mapper-internal projection). This was the program's single largest task.