# Auth β€” gaps & adoption backlog Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to reach the shared `ZB.MOM.WW.Auth` library. Status legend: β›” gap Β· 🟑 partial Β· βœ… matches. > **`0.2.0` PUBLISHED 2026-08-10 β€” LDAP backup-DC sticky failover** (plan > `docs/plans/2026-08-10-ldap-backup-dc-failover.md`). New capability in `Auth.Ldap`: an additive > `LdapOptions.FallbackServers` list makes `LdapAuthService` walk primary + fallback endpoints, > failing over on **system-side** faults only (connect/TLS, service-account bind, search) while a > user-side answer (`Success`/`UserNotFound`/`AmbiguousUser`/`BadCredentials`/`GroupLookupFailed`) > is final and never retried elsewhere. Sticky endpoint preference (updates on any authoritative > answer), boot validation of entries, optional `ILogger` observability. Empty default = failover > off, single-endpoint behaviour byte-unchanged. No `Abstractions` contract change beyond the new > option; no `LdapAuthFailure` enum change. Motivation: ZB production directory is NAM AD with two > DCs. **4 pkgs on the feed at `0.2.0`, restore-verified.** **App adoption is a follow-up** β€” all > four apps still pin `0.1.5`; adopting means bumping the pin to `0.2.0` and authoring > `FallbackServers` (the backup DC + LDAPS trust for the ZB 1024-bit certs), deployment-time work > tracked in `ldap_setup.md`. > **`0.1.5` PUBLISHED 2026-07-18 β€” security fix.** `Auth.ApiKeys` `0.1.4` and earlier ship a > high-severity transitive vuln: `Microsoft.Data.Sqlite 10.0.7` pulls > `SQLitePCLRaw.lib.e_sqlite3 2.1.11` (GHSA-2m69-gcr7-jv3q). `0.1.5` pins the patched `2.1.12` > (same approach as `ZB.MOM.WW.LocalDb`); the `.nuspec` declares it directly, so consumers get > the fix on bump without pinning themselves. **No API change** β€” `0.1.5` is `0.1.4` plus the pin. > **All three consumers bumped to `0.1.5` the same day** (mxaccessgw `e107019`, ScadaBridge > `50d79ed1`, HistorianGateway `df211ee` β€” all pushed). Exposure was verified per repo rather > than assumed: mxaccessgw and ScadaBridge genuinely resolved the vulnerable `2.1.11` and now > resolve `2.1.12`; **HistorianGateway was never exposed** (already resolved `3.50.3` via another > dependency), so its bump is consistency-only. ScadaBridge's `0.1.3`β†’`0.1.5` jump crosses the > `0.1.4` `ExpiresUtc` change, but that was additive β€” no source changes were needed and its > suites match baseline exactly (Security 181, InboundAPI 269). **OtOpcUa stayed at `0.1.1`** at > the time and was never exposed (it takes no `ApiKeys`/SQLite dependency) β€” it then bumped > `0.1.1`β†’`0.1.5` on 2026-07-20 for family alignment (`1adaa2fc`, PR #484, on `origin/master`). > > ⚠️ **Caveat β€” RESOLVED 2026-07-19:** HistorianGateway's bump could NOT be test-verified at the > time β€” a pre-existing, unrelated `AngleSharp` NU1902 error blocked its test project under > `TreatWarningsAsErrors` (ScadaBridge's `CentralUI.Tests` had the same break; both confirmed > present on clean trees beforehand). The HG blocker was fixed by HG commit `6bc005d` > ("fix(tests): pin AngleSharp 1.5.2 β€” clear GHSA-pgww-w46g-26qg NU1902 build break"), after which > the suite runs (854 pass; see the scadaproj `CLAUDE.md` index Secrets narrative). > > **Current state (re-verified 2026-08-07 against each repo's `Directory.Packages.props`):** feed > carries `0.1.0`–`0.1.5`; **all four consumers pin `0.1.5`** β€” mxaccessgw `e107019`, ScadaBridge > `50d79ed1`, HistorianGateway `df211ee` (all 2026-07-18), OtOpcUa `1adaa2fc` (2026-07-20). > (The previous snapshot here β€” OtOpcUa `0.1.1` / ScadaBridge `0.1.3` / MxAccessGateway `0.1.4` / > HistorianGateway `0.1.4` β€” predated the same-day bumps recorded in the banner above and > contradicted it.) HistorianGateway is a fourth consumer this file predates, taking all four > packages. Library suite: **215 passing, 1 skipped**, green 2026-07-18. > > **βœ… 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) and PUSHED to origin (gitea) on 2026-06-03** (in sync; `feat/*` kept locally). Shared > `Auth.Ldap` + `Auth.ApiKeys` (ScadaBridge inbound re-architected to keyId/Bearer), `IGroupRoleMapper`, > `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 | Spec key | OtOpcUa | MxAccessGateway | ScadaBridge | |---|---|---|---| | Section nesting | 🟑 `Authentication:Ldap` (nested) | βœ… `MxGateway:Ldap` (nested) | β›” flat `ScadaBridge:Security:Ldap*` | | `Transport` enum | β›” `UseTls` bool | β›” `UseTls` bool | βœ… `LdapTransport` enum | | `AllowInsecure` | 🟑 `AllowInsecureLdap` | 🟑 `AllowInsecureLdap` | 🟑 `AllowInsecureLdap` (rename) | | `UserNameAttribute` | βœ… `UserNameAttribute` | βœ… `UserNameAttribute` | β›” `LdapUserIdAttribute` | | `GroupAttribute` | βœ… `memberOf` | βœ… `memberOf` | 🟑 `LdapGroupAttribute` (rename) | | dev `SearchBase` | `dc=lmxopcua,dc=local` | `dc=lmxopcua,dc=local` | `dc=scadabridge,dc=local` | β†’ **Gap A1:** adopt the `Transport` enum in OtOpcUa + gateway (replace `UseTls`). β†’ **Gap A2:** ScadaBridge: nest keys + rename `LdapUserIdAttribute`β†’`UserNameAttribute`, `LdapGroupAttribute`β†’`GroupAttribute`. β†’ **Gap A3:** unify the **dev base DN** (`dc=lmxopcua` vs `dc=scadabridge`) β€” pick one shared GLAuth base. ### Β§2 bind-then-search All three do bind-then-search; ScadaBridge has the most complete hygiene (RFC-4514 + filter escaping, per-op timeout, fail-closed on group lookup, username trim, service-account-bind distinction). 🟑 OtOpcUa/gateway: confirm each has filter escaping + fail-closed-on-group-lookup parity. β†’ **Gap B1:** make ScadaBridge's hygiene the shared baseline; backfill any missing checks. ### Β§3 groupβ†’role mapping β›” **Mechanism split:** OtOpcUa + gateway map in **config** (`GroupToRole`); ScadaBridge maps in the **database** (`LdapGroupMapping`). β†’ **Gap C1:** `IGroupRoleMapper` must support both backings; ship a config-backed and a DB/delegate-backed mapper. β›” **Role vocabulary now standardized** to the canonical six ([`spec/CANONICAL-ROLES.md`](spec/CANONICAL-ROLES.md)); native enforcement stays per-project. β†’ **Gap C2:** implement the `canonical β†’ native` expansion in each project. ⚠ Removing Auditor collapses ScadaBridge `AuditReadOnly`β†’Viewer and `Audit`β†’Administrator, losing its auditor/admin separation-of-duties (accepted). OtOpcUa lacks a first-class `Deployer` (publish βŠ‚ `FleetAdmin`); ScadaBridge has no `Operator`/`Engineer`; mxaccessgw no `Designer`/`Deployer` β€” each project assigns only the applicable subset. ### Β§4 API-key contract | | OtOpcUa | MxAccessGateway | ScadaBridge | |---|---|---|---| | Has API keys | n/a (OPC UA transport security) | βœ… `mxgw_…`, SQLite, scopes + constraints | 🟑 `X-API-Key`, per-method approval (Inbound API only) | | Peppered HMAC-SHA256 | β€” | βœ… | βœ… | | Constant-time compare | β€” | βœ… | βœ… | | Token format `__` | β€” | βœ… | β›” raw `X-API-Key` (no keyId/prefix structure) | | Audit log | β€” | βœ… append-only | 🟑 (verify) | β†’ **Gap D1:** extract mxaccessgw's pipeline as `ZB.MOM.WW.Auth.ApiKeys`. β†’ **Gap D2:** ScadaBridge Inbound API adopts it; reconcile token format and model "per-method approval" as the opaque constraint policy. ### Β§5 cookie / claim conventions β›” Cookie names differ (`MxGatewayDashboard` vs `ZB.MOM.WW.ScadaBridge.Auth` vs OtOpcUa control-plane cookie); claim-type conventions differ. β†’ **Gap E1:** define canonical claim types + cookie defaults in `ZB.MOM.WW.Auth.AspNetCore`; each app keeps its own cookie *name* but shares attributes/claims. ### Β§6 dev / secrets βœ… All never-log-secrets and pepper-external. 🟑 escape-hatch flag names vary. Covered by A3 (base DN) + E. ## Adoption backlog (ordered) | # | Item | Projects | Priority | Effort | Risk | Notes | |---|---|---|---|---|---|---| | 1 | Extract `ZB.MOM.WW.Auth.Ldap` from ScadaBridge's hardened impl | all 3 | High | M | Med | security-sensitive; needs strong tests before cutover | | 2 | Extract `ZB.MOM.WW.Auth.ApiKeys` from mxaccessgw Model A | gw, SB | High | M | Med | gateway adopts first (it's the source), then SB | | 3 | `IGroupRoleMapper` seam + config & DB mappers (Gap C1) | all 3 | High | S | Low | unblocks per-project role retention | | 4 | Config migration to Β§1 schema (Gaps A1–A2) | all 3 | Med | S | Low | mechanical; do with #1 | | 5 | `ZB.MOM.WW.Auth.AspNetCore` claims/cookie conventions (Gap E1) | all 3 (UIs) | Med | S | Low | incl. OtOpcUa Blazor Admin UI control-plane | | 6 | Unify dev GLAuth base DN (Gap A3) | all 3 | Low | S | Low | dev-only; touches fixtures/infra | | 7 | Decide shared JWT/refresh helper vs per-project | SB (+?) | Low | S | Low | only if a 2nd project wants the same | | 8 | Adopt canonical roles: `canonical β†’ native` mapping per project (Gap C2) | all 3 | Med | M | Med | governance (assign canonical role per LDAP group org-wide) + each project's expansion; SB audit roles collapse | **Sequencing:** #3 first (cheap, unblocks), then #1 and #2 in parallel (independent libraries), then #4–#5 alongside cutover, then #6–#7 as cleanup. Each extraction lands behind tests in the source project before any consumer migrates. This stays consistent with the repos' loose coupling: adoption is opt-in per project, one consumer version-bump at a time. ## Decisions still open - Shared dev base DN value (A3). - Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3). - Shared JWT/refresh helper or not (#7).