Files
scadaproj/ZB.MOM.WW.Auth
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
..

ZB.MOM.WW.Auth

Authentication and authorisation libraries for the ZB.MOM.WW SCADA family (OtOpcUa, MxAccessGateway, ScadaBridge). These are libraries, not a service — each package is linked directly into the consuming application at build time. There is no central authentication process or network hop; auth logic runs in-process alongside the application.


Packages

Package Description Key Dependencies
ZB.MOM.WW.Auth.Abstractions Auth contracts, canonical role constants, and shared types (LdapOptions, LdapAuthResult, ILdapAuthService, IApiKeyStore). No runtime dependencies beyond the BCL. —
ZB.MOM.WW.Auth.Ldap LDAP authentication service: bind-then-search-then-bind against GLAuth or Active Directory; RFC 4514-aware group extraction; fail-closed. Abstractions, Novell.Directory.Ldap.NETStandard
ZB.MOM.WW.Auth.ApiKeys SQLite-backed API-key store with pepper-keyed HMAC-SHA256 secret hashing, rotation, and audit log. DI wiring is AddZbApiKeyAuth; an opt-in MigrationHostedService runs schema migrations on startup. Abstractions, Microsoft.Data.Sqlite
ZB.MOM.WW.Auth.ApiKeys.SqlServer SQL Server-backed API-key store sharing one row set across clustered hosts (e.g. a central pair), instead of each node keeping its own SQLite file. DI wiring is AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection), called instead of AddZbApiKeyAuth (it calls that internally). Also ships SqliteApiKeyStoreImporter — a one-shot per-node SQLite → shared store copy a host runs once at boot. Abstractions, ApiKeys, Microsoft.Data.SqlClient
ZB.MOM.WW.Auth.AspNetCore ASP.NET Core wiring for the LDAP provider only: AddZbLdapAuth (binds + start-time-validates LdapOptions, registers ILdapAuthService), plus ZbCookieDefaults.Apply (hardened cookie helper the consumer calls itself) and ZbClaimTypes constants. It does not wire API keys or cookie middleware — API-key DI is AddZbApiKeyAuth in the ApiKeys package. Abstractions, Ldap, Microsoft.AspNetCore.App

Consumer Matrix

Consumer Abstractions Ldap ApiKeys ApiKeys.SqlServer AspNetCore
OtOpcUa yes yes — — yes
MxAccessGateway yes yes yes — yes
ScadaBridge yes yes yes yes (central pair, 2026-09-02) yes

ApiKeys is NOT used by OtOpcUa (that app authenticates human operators via LDAP + cookies only; machine-to-machine access is out of scope).


Versioning

All five packages are versioned lockstep from Directory.Build.props. The current release is 0.4.0. A single version bump in Directory.Build.props bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.

0.4.0 — LDAP sign-in by user bind (opt-in)

  • LdapOptions.BindMode (ServiceAccount default | User), UserBindTemplate ({0} = the login name, e.g. {0}@example.com or EXAMPLE\{0}) and AcceptDomainQualifiedNames (default true).
  • In User mode LdapAuthService binds as the user; success authenticates them and the same connection reads their entry (UserNameAttribute, DisplayNameAttribute, GroupAttribute). No ServiceAccountDn / ServiceAccountPassword is needed or validated.
  • Failure mapping: empty password or an unusable name → BadCredentials with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → BadCredentials, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (ServiceAccountBindFailed) and the next endpoint. Zero/several entries or no groups after a good bind → GroupLookupFailed.
  • DOMAIN\name / name@suffix are reduced to name (or refused when AcceptDomainQualifiedNames is false), so the result's Username has one spelling.
  • No Abstractions contract change beyond the new enum and option properties; ServiceAccount mode is unchanged.
  • UserBindTemplate output is used verbatim as the bind name, so use a UPN ({0}@example.com) or down-level (EXAMPLE\{0}) template; a DN template is not escaped.

0.3.0 — SQL Server API-key store (ScadaBridge central pair)

  • IApiKeyStoreMigrator added to Abstractions: creates or upgrades an API-key store's schema; idempotent and safe to run concurrently from several nodes. SqliteAuthStoreMigrator implements it unchanged. AddZbApiKeyAuth registers the SQLite implementation via TryAdd, so a provider that registers its own migrator first (see below) wins — that ordering is the whole mechanism by which a different backend is swapped in.
  • New package ZB.MOM.WW.Auth.ApiKeys.SqlServer + AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection), called instead of AddZbApiKeyAuth (it calls it internally after registering the SQL Server stores and migrator first).
  • zbauth schema v1 (three tables: schema_version, api_keys, api_key_audit; DATETIMEOFFSET(7) timestamps). SqlServerApiKeyStoreMigrator is concurrent-boot safe — a central pair's two nodes both migrate at every boot — with retry on SQL error numbers 1205/2714/1913/2627 and jittered backoff (measured: the flat backoff it replaced exhausted its retries roughly 1 in 60 eight-way boots). DBA-facing DDL: docs/sql/zbauth-schema.sql.
  • SqliteApiKeyStoreImporter — idempotent insert-where-not-exists copy of an existing node's per-node SQLite keys and audit rows into the shared store; a row already present in SQL Server is never modified, so running it from both nodes of a pair converges on the union of their files.
  • ApiKeyAdminCommands gained a ctor taking IApiKeyStoreMigrator; the existing ctor taking the concrete SqliteAuthStoreMigrator is retained (MxAccessGateway, HistorianGateway and ScadaBridge all construct it that way).
  • SQLite remains the default store; existing consumers need no code change to move to 0.3.0.
  • Live tests for the SQL Server package are gated on AUTH_SQLSERVER_CONNSTR and skip (not fail) when it is unset.

0.2.1 — LDAP AD continuation-referral fix

  • NovellLdapConnection.Search now skips Active Directory continuation referrals (LdapReferralException from Next()) instead of letting one fail the whole search — a SearchBase at a domain root made every login fail as ServiceAccountBindFailed against a healthy directory. Non-referral faults still propagate untouched.

0.2.0 — version bump only

  • No functional change; bumped the lockstep family version ahead of the 0.2.1 LDAP fix.

0.1.4 — API-key expiry (archreview G-2)

  • ApiKeyRecord/ApiKeyListItem gain an optional DateTimeOffset? ExpiresUtc (nullable; NULL = never expires). ApiKeyFailure gains KeyExpired.
  • ApiKeyVerifier enforces expiry — a key whose ExpiresUtc is at or before "now" (inclusive, injected clock) fails with KeyExpired, decided before the secret comparison. Existing (never-expiring) keys are unaffected.
  • ApiKeyAdminCommands.CreateKeyAsync has a new overload taking DateTimeOffset? expiresUtc; the old signature delegates with null. RotateKeyAsync deliberately preserves an existing expiry (rotation changes only the secret).
  • SQLite schema → v3: a nullable expires_utc column, added to fresh DBs by the CREATE DDL and to existing v1/v2 DBs by an idempotent ALTER TABLE in the migrator (existing rows → NULL). A donor v2 gateway-auth.db is accepted (2 ≤ 3) and upgraded in place — no key is invalidated. Old-build readers tolerate the extra column.
  • Consumers must bump to 0.1.4 to set/enforce expiry (mxaccessgw + HistorianGateway benefit from the same verifier).

Running the opt-in LDAP integration test

The GLAuth integration test (GLAuthIntegrationTests) is skipped by default and does not affect the normal test run. To exercise it against a live GLAuth instance:

  1. Start the GLAuth Docker stack from the sibling repo:

    cd ~/Desktop/ScadaBridge/infra/glauth
    docker compose up -d
    
  2. Set the required environment variables and run the test:

    export ZB_LDAP_IT=1
    export ZB_LDAP_SVC_DN="cn=svc,dc=lmxopcua,dc=local"
    export ZB_LDAP_SVC_PW="svcpass"
    export ZB_LDAP_USER="alice"
    export ZB_LDAP_PW="alicepass"
    
    dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
      --filter "FullyQualifiedName~GLAuthIntegrationTests"
    

All other variables (ZB_LDAP_SERVER, ZB_LDAP_PORT, ZB_LDAP_BASE, ZB_LDAP_USERATTR) default to sensible GLAuth values and are optional. The test also probes TCP reachability before attempting auth and skips if the server is not contactable.


Publishing packages

Use build/push.sh to pack and push to the Gitea NuGet feed:

export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
export GITEA_NUGET_KEY="your-gitea-token"
./build/push.sh

The script runs dotnet pack -c Release then dotnet nuget push --skip-duplicate.


Design documentation

Full design docs live in the components/auth folder of the SCADA project notes:

  • ~/Desktop/scadaproj/components/auth/spec/SPEC.md — overall auth specification
  • ~/Desktop/scadaproj/components/auth/spec/CANONICAL-ROLES.md — role taxonomy
  • ~/Desktop/scadaproj/components/auth/shared-contract/ — shared contract types