Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).

This commit is contained in:
dohertj2
2026-06-01 03:59:23 -04:00
commit 37e23cf9f2
73 changed files with 6836 additions and 0 deletions
@@ -0,0 +1,346 @@
# 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).
@@ -0,0 +1,23 @@
{
"planPath": "docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md",
"repo": "~/Desktop/scadaproj/ZB.MOM.WW.Auth",
"tasks": [
{"id": 1, "nativeId": 6, "subject": "Task 1: Scaffold repo, solution, projects", "status": "pending", "blockedBy": []},
{"id": 2, "nativeId": 7, "subject": "Task 2: Abstractions — all contract types", "status": "pending", "blockedBy": [1]},
{"id": 3, "nativeId": 8, "subject": "Task 3: LDAP filter & DN escaping", "status": "pending", "blockedBy": [2]},
{"id": 4, "nativeId": 9, "subject": "Task 4: ILdapConnection seam + Novell adapter + fake", "status": "pending", "blockedBy": [2]},
{"id": 5, "nativeId": 10, "subject": "Task 5: LdapAuthService happy path + validation", "status": "pending", "blockedBy": [3, 4]},
{"id": 6, "nativeId": 11, "subject": "Task 6: LdapAuthService failure modes (fail-closed)", "status": "pending", "blockedBy": [5]},
{"id": 7, "nativeId": 12, "subject": "Task 7: GLAuth integration test (skippable)", "status": "pending", "blockedBy": [6]},
{"id": 8, "nativeId": 13, "subject": "Task 8: API-key token parser + secret generator", "status": "pending", "blockedBy": [2]},
{"id": 9, "nativeId": 14, "subject": "Task 9: Peppered HMAC hasher + constant-time compare", "status": "pending", "blockedBy": [2]},
{"id": 10, "nativeId": 15, "subject": "Task 10: SQLite schema + connection factory + migrator", "status": "pending", "blockedBy": [2]},
{"id": 11, "nativeId": 16, "subject": "Task 11: SqliteApiKeyStore (read + mark-used)", "status": "pending", "blockedBy": [10]},
{"id": 12, "nativeId": 17, "subject": "Task 12: SqliteApiKeyAdminStore + audit", "status": "pending", "blockedBy": [11]},
{"id": 13, "nativeId": 18, "subject": "Task 13: ApiKeyVerifier pipeline", "status": "pending", "blockedBy": [8, 9, 11]},
{"id": 14, "nativeId": 19, "subject": "Task 14: Reusable admin command set", "status": "pending", "blockedBy": [12]},
{"id": 15, "nativeId": 20, "subject": "Task 15: AspNetCore claim types, cookie defaults, DI", "status": "pending", "blockedBy": [5, 13]},
{"id": 16, "nativeId": 21, "subject": "Task 16: Packaging — nupkgs, push script, README", "status": "pending", "blockedBy": [7, 14, 15]}
],
"lastUpdated": "2026-06-01"
}