# 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.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 stays at `0.1.1`** and > was never exposed: it takes no `ApiKeys`/SQLite dependency. > > ⚠️ **Caveat:** HistorianGateway's bump could NOT be test-verified β€” a pre-existing, unrelated > `AngleSharp` NU1902 error blocks its test project under `TreatWarningsAsErrors` (ScadaBridge's > `CentralUI.Tests` has the same break). Both confirmed present on clean trees beforehand. > > **Current state (verified 2026-07-18):** feed carries `0.1.0`–`0.1.5`; consumer pins are > OtOpcUa `0.1.1`, ScadaBridge `0.1.3`, MxAccessGateway `0.1.4`, **HistorianGateway `0.1.4`** > (a fourth consumer this file predates, taking all four packages). OtOpcUa is three minors > behind β€” it does not get the `0.1.4` API-key-expiry enforcement. 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).