11 KiB
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.1. 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.1 — LDAP review fixes
- Canonical username (both modes): the search now also requests
UserNameAttribute, andLdapAuthResult.Usernameis the entry's first value of it (the directory's spelling), soJDoeandjdoesign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value. - Behaviour change for
ServiceAccount-mode consumers: an empty (or null) password is now refused by the library asBadCredentialsbefore any network call, in both bind modes. Previously onlyUsermode refused it; inServiceAccountmode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone. - Template guidance: prefer the down-level
UserBindTemplateEXAMPLE\{0}— it maps exactly to thesAMAccountNamethe group search uses. A UPN template ({0}@example.com) is only correct where every account's UPN prefix equals itssAMAccountName; AD resolves UPN binds against the explicituserPrincipalName, so a mismatch can fail sign-in or bind a different account than the one whose groups are read. - No
Abstractionscontract change.
0.4.0 — LDAP sign-in by user bind (opt-in)
LdapOptions.BindMode(ServiceAccountdefault |User),UserBindTemplate({0}= the login name, e.g.EXAMPLE\{0}— preferred, see 0.4.1 — or{0}@example.com) andAcceptDomainQualifiedNames(defaulttrue).- In
UsermodeLdapAuthServicebinds as the user; success authenticates them and the same connection reads their entry (UserNameAttribute,DisplayNameAttribute,GroupAttribute). NoServiceAccountDn/ServiceAccountPasswordis needed or validated. - Failure mapping: empty password or an unusable name →
BadCredentialswith 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@suffixare reduced toname(or refused whenAcceptDomainQualifiedNamesisfalse), so the result'sUsernamehas one spelling (0.4.1 also canonicalises its case from the entry).- No
Abstractionscontract change beyond the new enum and option properties;ServiceAccountmode is unchanged. UserBindTemplateoutput is used verbatim as the bind name, so use a down-level (EXAMPLE\{0}, preferred) or UPN ({0}@example.com, only where every UPN prefix equals thesAMAccountName) template; a DN template is not escaped.
0.3.0 — SQL Server API-key store (ScadaBridge central pair)
IApiKeyStoreMigratoradded toAbstractions: creates or upgrades an API-key store's schema; idempotent and safe to run concurrently from several nodes.SqliteAuthStoreMigratorimplements it unchanged.AddZbApiKeyAuthregisters the SQLite implementation viaTryAdd, 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 ofAddZbApiKeyAuth(it calls it internally after registering the SQL Server stores and migrator first). zbauthschema v1 (three tables:schema_version,api_keys,api_key_audit;DATETIMEOFFSET(7)timestamps).SqlServerApiKeyStoreMigratoris 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.ApiKeyAdminCommandsgained a ctor takingIApiKeyStoreMigrator; the existing ctor taking the concreteSqliteAuthStoreMigratoris 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_CONNSTRand skip (not fail) when it is unset.
0.2.1 — LDAP AD continuation-referral fix
NovellLdapConnection.Searchnow skips Active Directory continuation referrals (LdapReferralExceptionfromNext()) instead of letting one fail the whole search — aSearchBaseat a domain root made every login fail asServiceAccountBindFailedagainst 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/ApiKeyListItemgain an optionalDateTimeOffset? ExpiresUtc(nullable;NULL= never expires).ApiKeyFailuregainsKeyExpired.ApiKeyVerifierenforces expiry — a key whoseExpiresUtcis at or before "now" (inclusive, injected clock) fails withKeyExpired, decided before the secret comparison. Existing (never-expiring) keys are unaffected.ApiKeyAdminCommands.CreateKeyAsynchas a new overload takingDateTimeOffset? expiresUtc; the old signature delegates withnull.RotateKeyAsyncdeliberately preserves an existing expiry (rotation changes only the secret).- SQLite schema → v3: a nullable
expires_utccolumn, added to fresh DBs by the CREATE DDL and to existing v1/v2 DBs by an idempotentALTER TABLEin the migrator (existing rows →NULL). A donor v2gateway-auth.dbis 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:
-
Start the GLAuth Docker stack from the sibling repo:
cd ~/Desktop/ScadaBridge/infra/glauth docker compose up -d -
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