Files
scadaproj/ZB.MOM.WW.Auth/CLAUDE.md
T
Joseph Doherty b33f0f93c9 fix(auth): SQL Server key store refuses truncation and matches key ids exactly (Auth-008)
- Over-length key records (key_id > 64, prefix > 16, display name > 256) are
  refused with ArgumentException before connecting; an over-64 key-id lookup
  or mutation answers not-found without querying (SqlClient silently
  truncated the sized parameter, so a different id sharing the first 64
  characters matched).
- Every key_id predicate adds an exact VARBINARY comparison, so lookups are
  case- and trailing-space-exact like SQLite; the PK seek is unchanged and
  there is no schema change.
- SqliteApiKeyStoreImporter reports case-only key-id collisions and
  over-length rows in new init lists (KeyIdCollisions, KeysRejected) instead
  of counting them as skips or importing them truncated.
- Audit text truncation kept deliberately and documented.

Offline refused-port tests and live SQL Server 2022 tests fail pre-fix;
SqlServer suite 101/101 live. Auth findings now 0 open; CLAUDE.md records
the #46/#50 work; code-reviews/README.md regenerated.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:26:03 -05:00

16 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/.

Backlog / open work: lives in Gitea issues on dohertj2/scadaproj (tea issues list --repo dohertj2/scadaproj) — currently #5 (roll apps to 0.4.1), #6 (VD03 NAM AD cutover), #18 (Secrets migrator jitter, ported from here). The history below is a record, not a status tracker; file new follow-ups as issues.

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 → #18) 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.

0.4.4 (unpublished, scadaproj#40, Auth-007): BindMode=User now proves the entry whose groups it returns is the account that bound — RFC 4532 Who-am-I (dn: → DN match; AD u:DOMAIN\name → sAMAccountName match), falling back to the bind DN only for a DN template (GLAuth has no Who-am-I); otherwise GroupLookupFailed. {0} is RFC 4514-escaped in DN templates. Before the fix a user could sign in with another account's groups whenever the UPN/DN bind and the UserNameAttribute search named different accounts — reproduced live against a throwaway GLAuth (tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/, ZB_LDAP_USERBIND_IT=1, port 3897): pre-fix mallory signed in with Admins. Consumer impact: AD + down-level/UPN template unchanged for consistent accounts (BTDB Manager is the only user-bind consumer); non-AD user-bind needs a DN template that yields the entry's DN exactly as searched (GLAuth: cn={0},ou=<group>,ou=users,<base>). README §0.4.4.

0.4.4, continued (unpublished, scadaproj#46 + #50): still 0.4.4.

  • Auth-008 (ApiKeys.SqlServer): over-length key records are refused with ArgumentException; an over-64 key-id lookup answers not-found without querying; every key_id predicate adds an exact VARBINARY match, so lookups are case- and trailing-space-exact like SQLite. No schema change. ApiKeyImportResult gains the init lists KeyIdCollisions and KeysRejected, and KeysSkipped now counts exact duplicates only. Audit text is still truncated to 64 characters, deliberately (documented).
  • Auth-009: the canonical username is the UserNameAttribute value matching the typed name, ignoring case, not values[0].
  • Auth-010: the new ApiKeyFailure.StoreUnavailable (appended) is returned when FindByKeyIdAsync throws; only cancellation still throws.
  • #50 (user bind, AD forests): for a u:DOMAIN\name Who-am-I answer, the entry must be that domain's account. Where AD offers msDS-PrincipalName (a base read through the new internal ILdapConnection.Read), it must equal the answer. Otherwise the name must have exactly one holder under SearchBase. Not live-verified against AD.
  • Consumer-facing: a new enum value; SQL Server key ids now match exactly; over-length creates throw; the importer reports collisions and rejections.
  • Suites: 363 passed / 81 skipped offline; 440 passed / 4 skipped (LDAP live) with AUTH_SQLSERVER_CONNSTR, against a throwaway SQL Server 2022.

0.4.3 (unpublished, scadaproj#26): Microsoft.Data.Sqlite 10.0.7 -> 10.0.13 (efcore#39009 pool fault); SQLitePCLRaw.lib.e_sqlite3 transitive pin 2.1.12 -> 2.1.13 (10.0.13 floors 2.1.13). No API change.

0.4.2 (unpublished, scadaproj#23 + #22): ZB.MOM.WW.Auth.ApiKeys.ApiKeyPepperUnavailableException : InvalidOperationException (message still "pepper unavailable") is now thrown by ApiKeyAdminCommands create/rotate when the pepper is missing, so consumers can catch it by type; existing catch (InvalidOperationException) blocks keep working (only exact-type assertions like xunit ThrowsAsync<InvalidOperationException> need updating). Release note: consumers can drop their own IApiKeyPepperProvider pre-checks — mxaccessgw's DashboardApiKeyManagementService and any similar code. Also pins System.Security.Cryptography.Pkcs 9.0.21 (transitive, via SqlClient 6.0.2) to clear NuGetAudit NU1902/NU1903. Not yet published to the feed.