Files
scadaproj/docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md
T

347 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ZB.MOM.WW.Auth Shared Library Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Build the `ZB.MOM.WW.Auth` shared library set (4 NuGet packages) that normalizes LDAP identity, the canonical role seam, API-key auth, and ASP.NET cookie/claim conventions so OtOpcUa, MxAccessGateway, and ScadaBridge can stop re-implementing auth.
**Architecture:** A new standalone repo (`~/Desktop/scadaproj/ZB.MOM.WW.Auth`), .NET 10, four library projects with one DLL each — `Abstractions` (pure contracts), `Ldap` (bind-then-search authn), `ApiKeys` (peppered-HMAC keys + SQLite store), `AspNetCore` (cookie/claim/DI helpers). The reference implementations are lifted and generalized: `Ldap` from ScadaBridge's hardened `LdapAuthService`, `ApiKeys` from mxaccessgw's `Security/Authentication` pipeline. Libraries are linked into each consumer and copied to its `bin/`**no central auth service**. Consumer adoption is a **separate follow-on plan**; this plan delivers the library + tests + packages only.
**Tech Stack:** .NET 10, C#; xUnit + `Xunit.SkippableFact`; `Novell.Directory.Ldap.NETStandard`; `Microsoft.Data.Sqlite`; `Microsoft.Extensions.{DependencyInjection,Options}`, `Microsoft.AspNetCore.Authentication.*`; central package management (`Directory.Packages.props`); `.slnx` solution.
**Source references (read-only, to port from):**
- LDAP: `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs`, `SecurityOptions.cs`, `SecurityOptionsValidator.cs`
- API keys: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/*` (`ApiKeyParser`, `ApiKeySecretHasher`, `ApiKeySecretGenerator`, `ApiKeyVerifier`, `Sqlite*Store`, `SqliteAuthSchema`, `ApiKeyAdmin*`)
- Design: `~/Desktop/scadaproj/components/auth/spec/SPEC.md`, `spec/CANONICAL-ROLES.md`, `shared-contract/ZB.MOM.WW.Auth.md`
**Conventions for every task:** TDD (@superpowers-extended-cc:test-driven-development) — failing test first, minimal impl, green, commit. File-scoped namespaces, `sealed` by default, `Async` suffix on Task-returning methods. Never log secrets. Commit after each green task.
---
## Phase 0 — Scaffold
### Task 1: Create repo, solution, and project shells
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (everything depends on this)
**Files:**
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx`
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/Directory.Build.props`
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/Directory.Packages.props`
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/.gitignore`
- Create: `src/ZB.MOM.WW.Auth.Abstractions/ZB.MOM.WW.Auth.Abstractions.csproj`
- Create: `src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj`
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj`
- Create: `src/ZB.MOM.WW.Auth.AspNetCore/ZB.MOM.WW.Auth.AspNetCore.csproj`
- Create: `tests/ZB.MOM.WW.Auth.Ldap.Tests/…csproj`, `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/…csproj`, `tests/ZB.MOM.WW.Auth.AspNetCore.Tests/…csproj`
**Steps:**
1. `cd ~/Desktop && mkdir ZB.MOM.WW.Auth && cd ZB.MOM.WW.Auth && git init && dotnet new gitignore`
2. `dotnet new sln -n ZB.MOM.WW.Auth --format slnx` (if `.slnx` unsupported by the SDK, use default `.sln`).
3. Scaffold projects: `dotnet new classlib -f net10.0 -o src/<Name>` for the 4 libs; `dotnet new xunit -f net10.0 -o tests/<Name>.Tests` for the 3 test projects. Delete the default `Class1.cs`/`UnitTest1.cs`.
4. Project refs: `.Ldap`/`.ApiKeys`/`.AspNetCore``.Abstractions`; `.AspNetCore``.Ldap` + `.ApiKeys`; each test project → its lib (+ `.Abstractions`).
5. `Directory.Build.props`: `<TargetFramework>net10.0</TargetFramework>`, `<Nullable>enable</Nullable>`, `<ImplicitUsings>enable</ImplicitUsings>`, `<LangVersion>latest</LangVersion>`, `<Version>0.1.0</Version>` (lockstep), `<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>`.
6. `Directory.Packages.props`: pin `Novell.Directory.Ldap.NETStandard`, `Microsoft.Data.Sqlite`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.AspNetCore.Authentication.Cookies`/`.JwtBearer`, `Microsoft.AspNetCore.Authorization`, and test packages `xunit`, `xunit.runner.visualstudio`, `Xunit.SkippableFact`, `Microsoft.NET.Test.Sdk`.
7. `dotnet sln add` all projects; `dotnet build` to confirm the empty solution compiles.
8. **Commit:** `git add -A && git commit -m "chore: scaffold ZB.MOM.WW.Auth solution and projects"`
**Acceptance:** `dotnet build` green; 7 projects in the solution.
---
## Phase 1 — Abstractions (pure contracts)
### Task 2: Abstractions — all contract types
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (gates all impl tasks)
These are declarations only (no behavior) → no unit tests; correctness is enforced by compilation and by the impl-task tests that consume them.
**Files:**
- Create: `src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapOptions.cs` (+ `LdapTransport`, `LdapAuthResult`, `LdapAuthFailure`, `ILdapAuthService`)
- Create: `src/ZB.MOM.WW.Auth.Abstractions/Roles/CanonicalRole.cs` (+ `IGroupRoleMapper<TRole>`, `GroupRoleMapping<TRole>`)
- Create: `src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs` (`ApiKeyOptions`, `IApiKeyVerifier`, `ApiKeyVerification`, `ApiKeyIdentity`, `ApiKeyFailure`, `ApiKeyRecord`, `IApiKeyStore`, `IApiKeyAdminStore`, `ApiKeyAuditEntry`)
**Step 1: Write the types** — transcribe the contract from `components/auth/shared-contract/ZB.MOM.WW.Auth.md` verbatim (it is already valid C#). Canonical enum exactly:
```csharp
namespace ZB.MOM.WW.Auth.Abstractions.Roles;
public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator }
```
**Step 2: Build**`dotnet build src/ZB.MOM.WW.Auth.Abstractions`. Expected: success.
**Step 3: Commit**`git commit -am "feat(abstractions): auth contracts, canonical roles, api-key types"`
**Acceptance:** Abstractions compiles; no dependency beyond the BCL.
---
## Phase 2 — Ldap (authn) — parallel with Phase 3
### Task 3: LDAP filter & DN escaping
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 8, Task 9, Task 10
**Files:**
- Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEscaping.cs`
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEscapingTests.cs`
**Step 1: Failing tests** (port the escaping rules from ScadaBridge `LdapAuthService`):
```csharp
[Theory]
[InlineData("a*b", @"a\2ab")]
[InlineData("a(b)", @"a\28b\29")]
[InlineData(@"a\b", @"a\5cb")]
public void EscapesLdapFilterMetacharacters(string raw, string expected)
=> Assert.Equal(expected, LdapEscaping.Filter(raw));
[Fact]
public void EscapesDnPerRfc4514()
=> Assert.Equal(@"\#cn\,test", LdapEscaping.Dn("#cn,test"));
```
**Step 2:** `dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests --filter LdapEscapingTests` → FAIL.
**Step 3:** Implement `LdapEscaping.Filter` (escape `* ( ) \ NUL``\2a \28 \29 \5c \00`) and `LdapEscaping.Dn` (RFC 4514 leading `# space`, and `, + " \ < > ;`).
**Step 4:** Re-run → PASS.
**Step 5:** `git commit -am "feat(ldap): RFC-4514 / filter escaping"`
### Task 4: ILdapConnection seam + Novell adapter + fake
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8, Task 9, Task 10
**Files:**
- Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs` (+ `ILdapConnectionFactory`)
- Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs` (wraps `Novell.Directory.Ldap.LdapConnection`)
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs` (test double)
The seam exists so `LdapAuthService` logic is unit-testable without a live server. Methods: `Connect(host,port,transport,ct)`, `Bind(dn,password,ct)`, `Task<IReadOnlyList<LdapEntry>> Search(base,filter,attrs,ct)`, `Dispose`.
**Steps:** Write `FakeLdapConnection` first (records binds, returns scripted search results, can throw on a given bind to simulate bad creds) → it's the test harness for Task 5/6. Build. Commit `feat(ldap): connection seam + Novell adapter + test fake`.
### Task 5: LdapAuthService — happy path, transport enforcement, options validation
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 814 (different project)
**Blocked by:** Task 3, Task 4
**Files:**
- Create: `src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs`
- Create: `src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs`
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs`, `LdapOptionsValidatorTests.cs`
**Step 1: Failing tests** (inject `FakeLdapConnection`):
```csharp
[Fact]
public async Task SucceedsAndReturnsGroups_OnValidCredentials() {
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", memberOf: ["cn=Engineers,...", "cn=Viewers,..."]);
var svc = new LdapAuthService(Opts(), _ => fake, NullLogger);
var r = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
Assert.True(r.Succeeded);
Assert.Equal(["Engineers", "Viewers"], r.Groups); // CN= stripped
}
[Fact]
public void Validator_Rejects_PlainLdap_WhenNotAllowInsecure() {
var v = new LdapOptionsValidator().Validate(null, Opts(transport: LdapTransport.None, allowInsecure: false));
Assert.True(v.Failed);
}
```
**Step 2:** run → FAIL.
**Step 3:** Implement the bind-then-search flow per SPEC §2: connect (enforce `Transport` unless `AllowInsecure`); service-account bind; search `({UserNameAttribute}={LdapEscaping.Filter(username.Trim())})`; reject 0/≥2 matches; re-bind as user DN with password; read `GroupAttribute`; strip `CN=`. Validator enforces transport + required `Server`/`SearchBase`. Port logic from ScadaBridge `LdapAuthService.cs`.
**Step 4:** run → PASS.
**Step 5:** `git commit -am "feat(ldap): bind-then-search happy path + options validation"`
### Task 6: LdapAuthService — failure modes (fail-closed)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 814
**Blocked by:** Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs`
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailureTests.cs`
**Step 1: Failing tests** covering each `LdapAuthFailure`: `BadCredentials` (user bind throws), `UserNotFound` (0 search hits), `AmbiguousUser` (≥2 hits), `GroupLookupFailed`**must return Failed, never admit with zero groups**, `ServiceAccountBindFailed` (distinct from bad user creds), `Disabled` (Enabled=false). Assert each maps to the right enum and never throws to the caller.
**Step 2:** run → FAIL.
**Step 3:** Implement discriminated failures; wrap service-account bind failure distinctly; per-operation timeout (`ConnectionTimeoutMs`); username trim-normalization once at entry.
**Step 4:** run → PASS.
**Step 5:** `git commit -am "feat(ldap): fail-closed failure modes + distinct service-account errors"`
### Task 7: GLAuth integration test (skippable)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 814
**Blocked by:** Task 6
**Files:**
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthIntegrationTests.cs`
Use `[SkippableFact]``Skip.IfNot(GLAuth reachable on localhost:3893)`. Authenticate a known GLAuth user end-to-end through the real `NovellLdapConnection`; assert groups resolve. Document running GLAuth (reuse a sister repo's `infra/glauth`). Commit `test(ldap): skippable GLAuth integration test`.
---
## Phase 3 — ApiKeys (machine auth) — parallel with Phase 2
### Task 8: Token parser + secret generator
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 37
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyParser.cs`, `ApiKeySecretGenerator.cs`
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyParserTests.cs`, `ApiKeySecretGeneratorTests.cs`
**Step 1: Failing tests:** parse `mxgw_alice_SECRET``(KeyId="alice", Secret="SECRET")`; reject missing prefix / wrong prefix / malformed; prefix configurable. Generator returns 32-byte URL-safe base64, distinct each call (vary by calling twice).
**Steps 2-5:** port from mxaccessgw `ApiKeyParser.cs`/`ApiKeySecretGenerator.cs`; TDD red→green; commit `feat(apikeys): token parser + secret generator`.
### Task 9: Peppered HMAC hasher + constant-time compare
**Classification:** small (security-sensitive)
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 37
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretHasher.cs`
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretHasherTests.cs`
**Step 1: Failing tests:** same secret+pepper → identical hash; different pepper → different hash; verification uses `CryptographicOperations.FixedTimeEquals`; missing pepper throws `PepperUnavailable`-style.
**Step 3:** HMAC-SHA256 over the secret keyed by the pepper (resolved from `ApiKeyOptions.PepperSecretName` via injected secret provider). Port from mxaccessgw `ApiKeySecretHasher.cs`.
**Step 5:** commit `feat(apikeys): peppered HMAC-SHA256 hasher + constant-time compare`.
### Task 10: SQLite schema + connection factory + migrator
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 37
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/AuthSqliteConnectionFactory.cs`, `SqliteAuthSchema.cs`, `SqliteAuthStoreMigrator.cs`
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs`
**Step 1: Failing tests:** migrator on a temp DB creates `api_keys`, `api_key_audit`, `schema_version`; idempotent (run twice = no error); refuses a newer on-disk schema. Use a temp-file DB per test, deleted on dispose.
**Step 3:** WAL + busy_timeout; schema v1 = the three tables from mxaccessgw `SqliteAuthSchema.cs` (`api_keys`: key_id PK, key_prefix, secret_hash BLOB, display_name, scopes TEXT(json), constraints TEXT NULL, created_utc, last_used_utc NULL, revoked_utc NULL).
**Step 5:** commit `feat(apikeys): sqlite schema + connection factory + migrator`.
### Task 11: SqliteApiKeyStore (read + mark-used)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 37
**Blocked by:** Task 10
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs`
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs`
Tests: `FindByKeyIdAsync` returns inserted record incl. revoked; `FindActiveByKeyIdAsync` filters revoked; `MarkUsedAsync` updates `last_used_utc` only for active keys. Implement `IApiKeyStore`. Commit `feat(apikeys): sqlite read store + mark-used`.
### Task 12: SqliteApiKeyAdminStore + audit
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 37
**Blocked by:** Task 11
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs`, `SqliteApiKeyAuditStore.cs`
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs`
Tests: create→find; revoke sets `revoked_utc`; rotate replaces hash + clears revoked/last-used; delete only when revoked; every op appends an audit row (`ListRecentAsync`). Implement `IApiKeyAdminStore` + append-only audit. Commit `feat(apikeys): admin store (create/revoke/rotate/delete) + audit`.
### Task 13: ApiKeyVerifier pipeline
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 37
**Blocked by:** Task 8, Task 9, Task 11
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs`
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs`
**Step 1: Failing tests** for the full pipeline + each discriminated failure: `MissingOrMalformed`, `KeyNotFound`, `KeyRevoked`, `PepperUnavailable`, `SecretMismatch`, and success → `ApiKeyIdentity` (with scopes + opaque constraints, **no secret**). Assert success marks the key used.
**Step 3:** parse → `FindByKeyIdAsync` → reject revoked → hash with pepper → `FixedTimeEquals``MarkUsedAsync`. Port from mxaccessgw `ApiKeyVerifier.cs`. Opaque error to caller, discriminated reason for audit.
**Step 5:** commit `feat(apikeys): verification pipeline with discriminated failures`.
### Task 14: Admin command set
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 37
**Blocked by:** Task 12
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs` (+ parser/result types)
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs`
Reusable verb handlers (`init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key`, `delete-key`) returning structured results (so each consumer wires its own CLI front-end). `create-key` prints the assembled token once. Port from mxaccessgw `ApiKeyAdminCliRunner.cs`. Commit `feat(apikeys): reusable admin command set`.
---
## Phase 4 — AspNetCore
### Task 15: Claim types, cookie defaults, DI helpers
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Blocked by:** Task 5, Task 13
**Files:**
- Create: `src/ZB.MOM.WW.Auth.AspNetCore/ZbClaimTypes.cs`, `ZbCookieDefaults.cs`, `ServiceCollectionExtensions.cs`
- Test: `tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs`
**Step 1: Failing tests:** `AddZbLdapAuth(config)` binds `LdapOptions`, registers `ILdapAuthService` + validator (resolve from the built provider, assert non-null + options bound). `AddZbApiKeyAuth(config)` registers `IApiKeyVerifier` + stores + runs migrations flag. `ZbCookieDefaults` = HttpOnly, SameSite=Strict, configurable Secure, sliding idle. `ZbClaimTypes` defines name/display/username/role/scope-id constants.
**Steps 3-5:** implement DI extensions; TDD; commit `feat(aspnetcore): claim types, cookie defaults, DI helpers`.
---
## Phase 5 — Packaging
### Task 16: Pack metadata, produce nupkgs, publish script + README
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Blocked by:** Task 7, Task 14, Task 15
**Files:**
- Modify: the 4 library `.csproj` (PackageId, Description, Authors, RepositoryUrl, `<IsPackable>true`)
- Create: `build/pack.sh`, `build/push.sh`, `README.md`
**Steps:**
1. Add package metadata to each lib csproj; ensure inter-package deps surface as NuGet deps (`.Ldap``.Abstractions`, etc.).
2. `dotnet pack -c Release -o ./artifacts` → expect **4** `.nupkg` (Abstractions, Ldap, ApiKeys, AspNetCore) at version `0.1.0`.
3. `build/push.sh`: `dotnet nuget push ./artifacts/*.nupkg --source <gitea-feed> --api-key $GITEA_NUGET_KEY` (creds from env; do not hardcode).
4. `README.md`: package table, consumer matrix (OtOpcUa: Abstractions+Ldap+AspNetCore; gw & SB: all four; ApiKeys not OtOpcUa), versioning (lockstep), "library not service" note. Link the design docs in `scadaproj/components/auth/`.
5. `dotnet test` (full suite green, integration skipped if no LDAP).
6. **Commit:** `git commit -am "build: package metadata, pack/push scripts, README"`
**Acceptance:** `dotnet pack` emits 4 nupkgs; full unit suite green.
---
## Out of scope (separate follow-on plan)
Consumer **adoption** — migrating OtOpcUa / mxaccessgw / ScadaBridge onto these packages, the `canonical → native` role mappers per project, and config-key migration — is tracked in `scadaproj/components/auth/GAPS.md` (backlog #8) and warrants its own plan once `ZB.MOM.WW.Auth` `0.1.0` is published.
## Suggested execution order
`T1 → T2`, then the two chains in parallel: **Ldap** `T3,T4 → T5 → T6 → T7` and **ApiKeys** `T8,T9,T10 → T11 → T12 → T13`, `T14` after `T12`. Then `T15` (needs T5 + T13), then `T16` (needs T7 + T14 + T15).