Files
scadaproj/ZB.MOM.WW.Auth/CLAUDE.md
T
Joseph Doherty 63e6d3f867 docs(index): all four apps on Auth 0.3.0, OtOpcUa on LocalDb 0.3.4, MxGateway.Client/Contracts 0.2.1; Secrets consumer matrix corrected to 0.6.2 everywhere
Family-wide pin refresh of 2026-09-03: OtOpcUa b8b4c18f, mxgw 4f931e4,
HistorianGateway d8d890c, ScadaBridge e45c4af9 (Auth already on main).
Per-library narratives in ZB.MOM.WW.Auth/CLAUDE.md and
ZB.MOM.WW.LocalDb/CLAUDE.md updated to match; the stale
"unpushed / 0.6.0 / 0.5.1 / merge pending" notes are corrected.

Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 11:25:29 -04:00

12 KiB
Raw Blame History

ZB.MOM.WW.Auth

Shared login / identity / API-key library for the ZB.MOM.WW SCADA family (4 packages + ApiKeys.SqlServer; consumed by OtOpcUa, ScadaBridge, MxAccessGateway, HistorianGateway). Build/test from this directory: dotnet test (the ApiKeys.SqlServer live suite is gated on AUTH_SQLSERVER_CONNSTR). Component spec + gaps: ../components/auth/.

Index history (migrated from the scadaproj root CLAUDE.md)

Moved verbatim out of the scadaproj root CLAUDE.md on 2026-09-03 so it loads only when needed. This file is now the authoritative place for this history — append new entries here and keep the root index row short.

Component-table row as it read in the index

Component Status Goal Design Implementation
Auth (login / identity / authz) Adopted (feed carries 0.3.0 as of 2026-09-02 — five packages, the new one being ZB.MOM.WW.Auth.ApiKeys.SqlServer; ALL FOUR apps pin 0.3.0 as of 2026-09-03: OtOpcUa master @ b8b4c18f, ScadaBridge main @ e45c4af9 (the inbound-api-key-shared-store work, merged), MxAccessGateway main @ 4f931e4, HistorianGateway main @ d8d890c — each built + tested with no new failures versus its pre-bump baseline, all PUSHED to origin. The preceding 0.2.1 pins landed 2026-08-13 (OtOpcUa 08e33fc7, ScadaBridge d28824d9, MxAccessGateway 1cb14d2, HistorianGateway e42ff31). 0.2.0 (2026-08-10) adds LDAP backup-DC sticky failover, 0.2.1 (2026-08-12) fixes a defect that broke AD logins outright — an AD continuation referral escaped NovellLdapConnection.Search, so any SearchBase at a domain root failed every login as ServiceAccountBindFailed against a perfectly healthy directory. Live-proven against DC=nam,DC=zimmer,DC=com; the app-level exposure is closed by the pin bumps. FallbackServers is live-gated via the local redundant GLAuth pair (infra/glauth-redundant/, primary :3893 + backup :3894 on the dev Mac): HistorianGateway dashboard drill PASS 2026-08-13 (failover, stickiness, walk-back). Shared ZB.MOM.WW.Auth lib components/auth/ ZB.MOM.WW.Auth/

Narrative

The auth component is fully populated: a normalized spec, a proposed shared-contract, three current-state docs, and an adoption GAPS backlog. Common ground = LDAP/GLAuth identity + peppered-HMAC API keys; left per-project = the authz vocabularies (OPC-UA permissions / gRPC scopes / roles + site-scoping).

The shared library is built and lives in this repo at ZB.MOM.WW.Auth/ (a plain directory of this repo — no nested .git, verified 2026-08-07; .NET 10; 4 packages — Abstractions, Ldap, ApiKeys, AspNetCore; 250 tests passing + 2 skipped (verified 2026-08-10 at 0.2.0; was 215+1 at 0.1.5); feed carries 0.1.0–0.2.0). The implementation plan is at docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md. Adopted across all three apps on 2026-06-02 (auth GAPS #1–#8) on each repo's feat/adopt-zb-auth branch — committed + reviewed, then fast-forward-merged into the repo's local default (main/master) and PUSHED to origin (gitea) on 2026-06-03 (in sync; the feat/* branches kept locally as history). Cutover: shared Auth.Ldap, Auth.ApiKeys (ScadaBridge inbound fully re-architected to the keyId/Bearer model), IGroupRoleMapper<TRole> seam, Transport-enum config, canonical ZbClaimTypes/ZbCookieDefaults, unified dev base DN dc=zb,dc=local, and the canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: all four apps pin 0.3.0 as of 2026-09-03 (commits listed in the table row above, all pushed to origin); all four pinned 0.2.1 from 2026-08-13 before that. Prior history — all four pinned 0.1.5 (verified 2026-08-07): MxGateway via e107019 2026-07-18 (earlier bumped 0.1.2→0.1.4 2026-07-09 for the G-2 API-key-expiry verifier enforcement — commit 197731a; also required implementing the 0.1.3 IApiKeyAdminStore.SetScopes/SetEnabled members on a test fake), ScadaBridge 50d79ed1 + HistorianGateway df211ee 2026-07-18, OtOpcUa 1adaa2fc 2026-07-20 (PR #484, family alignment from 0.1.1). Per-repo detail in components/auth/GAPS.md + docs/plans/2026-06-02-auth-audit-normalization*.md. Build/test from ZB.MOM.WW.Auth/: dotnet test (the ApiKeys.SqlServer live suite is gated on AUTH_SQLSERVER_CONNSTR, skipped otherwise). Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore; MxAccessGateway & HistorianGateway → the original four; ScadaBridge → those four plus ApiKeys.SqlServer (ApiKeys not used by OtOpcUa). 0.1.4 (2026-07-09, archreview G-2): optional ApiKeyRecord.ExpiresUtc (nullable = never-expires), ApiKeyFailure.KeyExpired, ApiKeyVerifier expiry enforcement (inclusive, injected clock), a CreateKeyAsync(expiresUtc) overload, and a SQLite schema v3 additive expires_utc column (idempotent ALTER; donor v2 DBs upgrade in place, no key invalidated). All four Auth pkgs published to the Gitea feed at 0.1.4 (2026-07-09). HistorianGateway adopted the expiry surface at 0.1.4 — apikey create --expires + dashboard Expires column (Task-10 done). 0.1.5 (2026-07-18) is 0.1.4 plus the SQLitePCLRaw.lib.e_sqlite3 2.1.12 transitive security pin (GHSA-2m69-gcr7-jv3q), no API change; all four apps now pin it (see consumer pins above). 0.2.0 (2026-08-10) — LDAP backup-DC sticky failover (plan docs/plans/2026-08-10-ldap-backup-dc-failover.md, design …-design.md; 4 pkgs published to the feed, restore-verified; family Directory.Build.props now 0.2.0): one additive LdapOptions.FallbackServers list ("host" or "host:port", empty default = failover off, single-endpoint behaviour byte-unchanged) makes LdapAuthService walk an ordered endpoint list (primary + fallbacks) on system-side failures only — connect/TLS, service-account bind, or search. A user-side answer from a healthy directory (Success/UserNotFound/AmbiguousUser/BadCredentials/GroupLookupFailed) is final and never retried elsewhere (re-asking a second DC about a rejected password would multiply one bad-password event into several against the account lockout policy). Endpoint preference is sticky — the last endpoint that gave any authoritative answer (success or a user-side failure) is tried first next time — so a primary outage costs its connect timeout once, not per login; an exhausted walk leaves the preference untouched. Fail-closed contract preserved per-endpoint; boot validation rejects a malformed FallbackServers entry naming the field; optional ILogger<LdapAuthService> emits a Warning per failed hop (endpoint + stage, no secrets) and an Information on sticky change; wired via AddZbLdapAuth (GetService, so logging is optional). Motivation: the ZB production directory is NAM AD with two DCs. Unit suite covers the walk/stickiness (14 tests) + env-gated live GLAuth failover test (ZB_LDAP_IT=1). No Abstractions contract change beyond the new option; no LdapAuthFailure enum change. App adoption (pin bump to 0.2.0 + authoring FallbackServers) is a follow-up. 0.2.1 (2026-08-12) — AD continuation referrals no longer fail the search (4 pkgs published to the feed, restore-verified; the assembly restored from the feed carries the fix and 0.2.0 does not — checked as a positive control, not by trusting the push). Active Directory answers any search based at a domain root with continuation references (to CN=Configuration, DC=ForestDnsZones, …). With referral following disabled — the default, and what we want, since chasing one would re-bind somewhere we never authenticated — Novell surfaces each by throwing LdapReferralException from Next(). That escaped NovellLdapConnection.Search, and LdapAuthService maps a failed search to ServiceAccountBindFailed, so every login against SearchBase = DC=nam,DC=zimmer,DC=com failed system-side on both DCs while the directory, the credentials and the service account were all healthy — ~546 ms, identical on each endpoint, so failover could not help either. Found by the new tools/LdapProbe (below) and live-proven fixed against the ZB NAM domain root. The search loop now skips referrals (ReadEntries(ILdapSearchResults), extracted so it is unit-testable through the Novell seam without a live directory) and continues; a 1000-referral cap guards a client that throws without advancing, since that would spin forever inside a fail-closed auth path. Non-referral faults still propagate — swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 new tests (NovellSearchReferralTests), 6 verified red against the pre-fix code, the seventh being the non-referral control that must stay green; suite 257 pass / 2 skip / 0 fail. No Abstractions change; no config change. Consumer impact: an app on 0.1.5 cannot authenticate against an AD domain root at all, so the pin bump is a functional fix, not housekeeping. Closed 2026-08-13: all four apps adopted 0.2.1 (see the table row for commits), and FallbackServers was live-gated the same day against the redundant local GLAuth pair (infra/glauth-redundant/).

0.3.0 (2026-09-02) — ZB.MOM.WW.Auth.ApiKeys.SqlServer, a SQL Server provider for the API-key store (design ScadaBridge/docs/plans/2026-09-02-inbound-api-key-shared-store-design.md; 5 pkgs published to the feed, ApiKeys.SqlServer search-verified from the ScadaBridge checkout; family Directory.Build.props now 0.3.0). Motivation: Auth.ApiKeys' SQLite store is a per-node file, so on a two-node active/standby host (ScadaBridge central) a key minted on one node exists only there and a failover 401s every inbound caller (ScadaBridge deferred-work row 108). The new package implements IApiKeyStore/IApiKeyAdminStore/IApiKeyAuditStore over Microsoft.Data.SqlClient in a zbauth schema (v1: schema_version, api_keys, api_key_audit; all timestamps DATETIMEOFFSET(7), tick-exact) with a concurrent-boot-safe migrator (Serializable tx, retry on 1205/2714/1913/2627 with jittered backoff — the Secrets replicator's flat 100*attempt backoff exhausted five retries about once per sixty 8-way boots in the port's soak test, so the same latent defect is an open follow-up there) and a SqliteApiKeyStoreImporter that copies an existing SQLite file into the schema insert-where-absent (idempotent, so both nodes of a pair can import their own file and the result is the union). Wiring: services.AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection) registers the SQL Server stores + migrator before delegating to AddZbApiKeyAuth, whose TryAdd* registrations then yield (same displacement mechanism as Secrets 0.5.0). One Abstractions addition, additive: IApiKeyStoreMigrator, which SqliteAuthStoreMigrator now implements and ApiKeyAdminCommands accepts via a new ctor (old SqliteAuthStoreMigrator ctor retained — the three 0.2.1 consumers compile unchanged). Store semantics are pinned to the SQLite contract tests re-run live (mark-used skips revoked keys, delete only removes revoked keys, rotate clears last-used/revoked, list newest-first). 92 tests in the new project, 351 across the solution, 0 failed. A DBA-run copy of the DDL ships at docs/sql/zbauth-schema.sql.

tools/LdapProbe (ldapprobe, not packable, outside the Auth solution) is the diagnostic that found the above: it drives the real LdapAuthService so its verdict is what the four apps produce, then separately queries the directory for what the library cannot report — full group DNs, nested groups via LDAP_MATCHING_RULE_IN_CHAIN, the primary group (which AD omits from memberOf, so it can never be a role group), group type (security vs distribution), decoded userAccountControl, referral counts, and the TLS chain with key sizes. --list-groups '<pat>' enumerates every group in the directory matching a pattern, which a membership dump structurally cannot do. Findings recorded in ldap_setup.md, including the UG_WW_* Wonderware group convention (39 groups) that partly overturns that doc's earlier "no group→role convention exists" claim — it was true of Ignition, false of the domain.