# 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 1–3 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="" ``` **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 ``` **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 `` 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 `` 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` 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` (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 `__`; 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 / A1–A2, 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) 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/_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.2–3.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.