Commit Graph

29 Commits

Author SHA1 Message Date
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
Joseph Doherty c4366539ca fix(auth): ApiKeyVerifier reports store faults as StoreUnavailable (Auth-010)
A key-store lookup that threw (shared SQL Server unreachable, SQLite I/O
fault) escaped VerifyAsync despite the "only exception path is cancellation"
contract, surfacing as an unaudited 500 / gRPC Unknown. ApiKeyFailure gains
StoreUnavailable (appended; numeric values unchanged) and the lookup maps any
non-cancellation exception to it. Unit theory and an offline refused-port
SqlServerApiKeyStore test fail pre-fix.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:21:00 -05:00
Joseph Doherty b8d3fa2ba1 fix(auth): prove the AD domain of a u:DOMAIN\name user bind
Auth-007's sAMAccountName match assumed the search base covers one AD
domain; across a forest (Global Catalog) it could accept another domain's
same-named account and return its groups. For a u:DOMAIN\name Who-am-I
answer the entry must now also be that domain's account: AD's
msDS-PrincipalName (objectSid-derived DOMAIN\name, read by a base search
through the new ILdapConnection.Read) must equal the answer where offered;
otherwise the name must have exactly one holder under SearchBase and it must
be the entry found. Three forest cases fail pre-fix; single-domain and
sAMAccountName-search compat cases pass both ways. README notes this and
Auth-009.

(scadaproj#50)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:18:46 -05:00
Joseph Doherty 9ec0da1b2c fix(auth): canonical username is the attribute value the user matched (Auth-009)
ExtractUserName returned the first value of a possibly multi-valued
UserNameAttribute (default cn), so jsmith could sign in as "John Smith"
depending on server ordering. It now prefers the value equal to the typed
name ignoring case (still canonicalising case) and falls back to the first
value only when none matches.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:18:25 -05:00
Joseph Doherty d549a782fd fix(auth): user bind proves the bound account is the entry whose groups it returns (scadaproj#40)
BindMode=User bound as UserBindTemplate({0}) and then returned the groups of
whatever entry (UserNameAttribute={0}) found - two directory lookups that can
name two accounts (Auth-007). Reproduced live against GLAuth: mallory bound as
herself and signed in with boss's Admins group.

After the bind the service now asks RFC 4532 Who-am-I and requires the entry
to be that account (dn: -> DN match; AD u:DOMAIN\name -> sAMAccountName match).
Without a Who-am-I answer only a DN template is provable (bind DN must equal
the entry DN); anything else fails closed as GroupLookupFailed. {0} is
RFC 4514-escaped in DN templates; validator message leads with EXAMPLE\{0}.

Auth 0.4.3 -> 0.4.4 (unpublished).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:02:16 -05:00
Joseph Doherty 01c6dc1d02 build(libs): point package RepositoryUrl/PackageProjectUrl at scadaproj (the zb-mom-ww-* repos do not exist)
All 22 package projects named gitea.dohertylan.com/dohertj2/zb-mom-ww-<lib>,
which return 404; the libraries live as plain directories in scadaproj.
Takes effect on each library's next published version.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:42:29 -05:00
Joseph Doherty 243c3fa619 feat(auth): typed ApiKeyPepperUnavailableException for missing API-key pepper; bump to 0.4.2 (scadaproj#23)
Derives from InvalidOperationException so existing catches keep working.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 12:30:04 -05:00
Joseph Doherty 9090b1b511 docs(auth): prefer the down-level UserBindTemplate; warn on UPN templates (I1) 2026-10-01 01:27:55 -04:00
Joseph Doherty e3d146aa21 fix(auth): canonical username from the entry; refuse empty passwords in both bind modes
- Request UserNameAttribute in the search (de-duplicated, case-insensitive) and return its
  first value as LdapAuthResult.Username, falling back to the typed name (I2).
- The empty-password guard now applies in ServiceAccount mode too: AD treats an empty-password
  simple bind as an unauthenticated bind and reports success (I3).
- Restore the dropped explanatory comments in CompleteWithServiceAccount / SearchUser.
2026-10-01 01:27:27 -04:00
Joseph Doherty 302d1245cd feat(auth): LdapAuthService user-bind flow (bind as the user, read own groups on the same connection) 2026-10-01 00:55:04 -04:00
Joseph Doherty fa0ca66a05 feat(auth): normalise DOMAIN\name and name@suffix logins for user bind 2026-10-01 00:48:25 -04:00
Joseph Doherty 711e8beb2b feat(auth): LdapBindMode.User options and validation 2026-10-01 00:47:04 -04:00
Joseph Doherty de21669ad5 feat(auth)!: 0.3.0 — ZB.MOM.WW.Auth.ApiKeys.SqlServer package (AddZbApiKeyAuthSqlServer, importer), README/changelog
The `!` marks the new package + version, not an API break — SQLite stays default and
existing consumers need no code change.

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 14:39:35 -04:00
Joseph Doherty dcee4199ab feat(auth.apikeys.sqlserver): SqliteApiKeyStoreImporter — idempotent insert-where-not-exists copy of keys + audit (union across a pair)
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 14:35:31 -04:00
Joseph Doherty b5da1c8c57 feat(auth.apikeys.sqlserver): IApiKeyStore/IApiKeyAdminStore/IApiKeyAuditStore over SQL Server, semantics pinned by the SQLite contract tests re-run live
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 14:29:58 -04:00
Joseph Doherty c1e34c307e feat(auth.apikeys.sqlserver): zbauth schema v1 + concurrent-boot-safe migrator (retry on 1205/2714/1913/2627, newer-version fail-closed); live tests gated on AUTH_SQLSERVER_CONNSTR
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 14:22:25 -04:00
Joseph Doherty 2969805e4e feat(auth.apikeys.sqlserver): package skeleton, options (schema default zbauth, validated) and connection factory
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 14:08:22 -04:00
Joseph Doherty 3f11704d10 feat(auth.apikeys): IApiKeyStoreMigrator abstraction — SQLite implements it, DI registers it via TryAdd so a provider registered first wins; ApiKeyAdminCommands accepts the interface (old ctor kept)
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 14:06:14 -04:00
Joseph Doherty 1f8ab8e3b3 fix(auth): skip AD continuation referrals in LDAP search (0.2.1)
Active Directory answers any search based at a domain root with continuation
references (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 in ~546 ms
while the directory, the credentials and the service account were all healthy.
Identical on each endpoint, so failover could not help either.

A continuation reference is a pointer to another naming context, never a
statement that this search failed, so the loop now skips them and continues --
returning exactly the entries in the naming context we bound to. Extracted as
ReadEntries(ILdapSearchResults) so the handling is unit-testable through the
Novell seam without a live directory. A 1000-referral cap guards a client that
throws without advancing, which would otherwise spin forever inside a
fail-closed auth path.

Non-referral faults still propagate untouched: swallowing those would turn
"the directory is broken" into "no such user", which is the wrong answer to
give about someone's credentials.

7 tests, 6 verified red against the pre-fix code; the seventh is the
non-referral control that must stay green either way. Suite 257/0, 2 skipped.

Live-proven against the ZB NAM domain root before and after.
2026-08-12 21:27:05 -04:00
Joseph Doherty 92ff6a0d8c feat(ldap): failover observability — warn per hop, info on sticky change 2026-08-10 16:38:38 -04:00
Joseph Doherty 174ad952dc feat(ldap): endpoint walk with sticky-preference failover in LdapAuthService 2026-08-10 16:37:14 -04:00
Joseph Doherty 8997a46b74 feat(ldap): FallbackServers option + boot-time validation 2026-08-10 16:33:32 -04:00
Joseph Doherty 5fbf53601c feat(ldap): LdapEndpoint + FallbackServers entry parser 2026-08-10 16:32:27 -04:00
Joseph Doherty 378880f0ed feat(apikeys): optional ExpiresUtc — create + verifier enforcement + sqlite v3 migration (archreview G-2)
ApiKeyRecord/ApiKeyListItem gain a nullable ExpiresUtc (NULL = never
expires); ApiKeyFailure gains KeyExpired. ApiKeyVerifier rejects a key
whose ExpiresUtc is at-or-before now (inclusive, injected clock), before
the secret comparison. CreateKeyAsync gets an expiresUtc overload; rotate
preserves existing expiry. SQLite schema bumps to v3: a nullable
expires_utc column, added to fresh DBs via CREATE and to existing v1/v2
DBs via an idempotent guarded ALTER — donor v2 gateway-auth.db upgrades
in place, no key invalidated. Version 0.1.3 -> 0.1.4 (not yet published;
nuget push is human-gated). Consumers (HistorianGateway, mxaccessgw) bump
to 0.1.4 to set/enforce expiry. 146 Auth.ApiKeys tests pass.
2026-07-09 06:04:54 -04:00
Joseph Doherty 468959ca8a feat(auth.apikeys): add IApiKeyAdminStore.SetScopesAsync + SetEnabledAsync (editable scopes + reversible enable, no schema change); bump 0.1.3 2026-06-02 03:08:19 -04:00
Joseph Doherty da669bfc9b fix(auth.apikeys): stamp schema version 2 to match donor gateway DBs; bump 0.1.2
The store was extracted from MxAccessGateway, whose deployed gateway-auth.db
is at schema_version=2. The library capped at 1 and threw on a newer on-disk
version -> gateway would fail to boot. Final schema is byte-identical since v1;
stamp 2 so existing deployed DBs interoperate (no key re-issuance). +2 tests.
2026-06-02 01:45:57 -04:00
Joseph Doherty aecc106657 fix(auth.ldap): skip LdapOptionsValidator when Enabled=false; bump 0.1.1
A disabled LDAP provider's connection fields are inert — don't require
Server/SearchBase/ServiceAccountDn at startup when Enabled=false. Surfaced
by the MxGateway 1.2 review (dashboard LDAP can be disabled). +1 test.
2026-06-02 01:17:53 -04:00
Joseph Doherty 544a6ddb77 Fix all baseline code-review findings across the six shared libraries
Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).

- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
  redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
  best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
  validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
  GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
  endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
  TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
  record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
  quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
  XML docs on the public parameter surface.

Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
2026-06-01 11:22:14 -04:00
dohertj2 37e23cf9f2 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). 2026-06-01 03:59:23 -04:00