b33f0f93c9
- 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
147 lines
16 KiB
Markdown
147 lines
16 KiB
Markdown
# 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.4 — user bind proves the bound account is the one whose groups it returns (unpublished, scadaproj#40)
|
|
|
|
- **Security fix (Auth-007), `BindMode = User` only.** The bind name (`UserBindTemplate`) and the group search (`UserNameAttribute`) are two separate directory lookups and could name two accounts — e.g. AD resolves `jsmith@corp.com` to the account with that explicit UPN while `(sAMAccountName=jsmith)` finds a different one, so a user could sign in with another account's groups. The service now only accepts the entry it found when it is provably the account that bound:
|
|
- it asks the directory with RFC 4532 **Who-am-I** after the bind; a `dn:<dn>` answer must equal the entry's DN (`distinguishedNameMatch`), and AD's `u:DOMAIN\name` answer must equal the entry's `sAMAccountName` (requested automatically);
|
|
- a server without Who-am-I (GLAuth answers protocol error 2) is supported only with a **DN template** (contains `=`), whose bind DN must equal the entry's DN;
|
|
- anything else — a mismatch, an unanswered Who-am-I with a UPN or down-level template — is `GroupLookupFailed`, logged at Warning, and never retried on a fallback endpoint.
|
|
- **`{0}` is now RFC 4514-escaped in a DN template**, so a typed name such as `x,ou=Admins` cannot rewrite the bind DN.
|
|
- **Behaviour change for consumers:** AD with a down-level or UPN template is unaffected for accounts whose bound identity and searched entry agree (AD has supported Who-am-I since Windows Server 2003). A UPN template now refuses accounts whose UPN prefix differs from `sAMAccountName` instead of risking the wrong account. A **non-AD directory** in `User` mode needs either Who-am-I support or a DN template that yields the entry's DN *exactly as the search returns it* — for GLAuth that is `cn={0},ou=<group>,ou=users,<base>`, not `cn={0},ou=<group>,<base>`. ServiceAccount mode is unchanged.
|
|
- The validator's `UserBindTemplate` message now leads with `EXAMPLE\{0}`. No `Abstractions` contract change (XML docs only).
|
|
- **AD forests (scadaproj#50).** `sAMAccountName` is unique per *domain*, so with a search base that spans a forest (a Global Catalog) a matching `sAMAccountName` alone could be another domain's account of the same name. For AD's `u:DOMAIN\name` answer the entry must now also be proven to be that domain's account:
|
|
- where the directory computes `msDS-PrincipalName` for the entry (a base read; AD's `DOMAIN\name`, derived from `objectSid`), it must equal the Who-am-I answer, domain included;
|
|
- otherwise the name must have exactly one holder under `SearchBase`, and it must be the entry found — no extra search is made when `UserNameAttribute` is `sAMAccountName` and the user typed the bound name, because the user search already proved that. Two holders → `GroupLookupFailed`.
|
|
- A single-domain AD (BTDB Manager) is unaffected. Not yet live-verified against AD — the fake connection covers the paths; GLAuth has no Who-am-I.
|
|
- **`ApiKeyFailure.StoreUnavailable` (Auth-010, `Abstractions` addition).** `ApiKeyVerifier.VerifyAsync` no longer lets a key-store fault escape: a `FindByKeyIdAsync` that throws (shared SQL Server unreachable, a SQLite I/O fault) returns `Succeeded = false` with the new `StoreUnavailable` failure, so it takes the host's normal — audited — auth-failure path instead of an unhandled 500 / gRPC `Unknown`. Cancellation still throws. The value is appended, so existing numeric values are unchanged; a host may answer it with 503 rather than 401. A misconfigured `SqlServerApiKeyOptions` still throws when the store is first resolved (outside `VerifyAsync`).
|
|
- **SQL Server store: no truncation, exact key ids (Auth-008).** The `ApiKeys.SqlServer` stores now behave like the SQLite store they port:
|
|
- `CreateAsync` throws `ArgumentException` for a key id over 64, prefix over 16 or display name over 256 characters (it used to store them silently truncated), and every lookup of an over-64-character key id answers "not found" without querying (a truncated parameter could match a different key sharing its first 64 characters).
|
|
- Key-id matching is **exact** — case- and trailing-space-sensitive — via a binary comparison added to every `key_id` predicate; the primary-key seek is unchanged and there is **no schema change**. A token presented with a case-altered key id (`LINE1` for `line1`) now fails `KeyNotFound` instead of resolving.
|
|
- `SqliteApiKeyStoreImporter` reports instead of hiding: `ApiKeyImportResult.KeyIdCollisions` lists source keys whose id differs only in case from one already in SQL Server (the primary key follows the database collation and cannot hold both — re-mint those keys), and `KeysRejected` lists over-length keys; neither is counted in `KeysSkipped` any more. Both are new `init` properties, so existing code is unaffected.
|
|
- Audit text (`key_id`, `event_type`, `remote_address`) is still truncated to 64 characters — deliberately, and now documented: an audit append runs after the action it records and must not fail it.
|
|
- **Canonical username from a multi-valued attribute (Auth-009).** `LdapAuthResult.Username` is now the `UserNameAttribute` value that equals the typed name ignoring case — `cn: [John Smith, jsmith]` signs `jsmith` in as `jsmith`, not whichever value the server lists first. The first value is used only when none matches. Both bind modes.
|
|
|
|
### 0.4.1 — LDAP review fixes
|
|
|
|
- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign 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 as `BadCredentials` before any network call, in both bind modes. Previously only `User` mode refused it; in `ServiceAccount` mode 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 `UserBindTemplate` `EXAMPLE\{0}` — it maps exactly to the `sAMAccountName` the group search uses. A UPN template (`{0}@example.com`) is only correct where every account's UPN prefix equals its `sAMAccountName`; AD resolves UPN binds against the explicit `userPrincipalName`, so a mismatch can fail sign-in or bind a different account than the one whose groups are read.
|
|
- No `Abstractions` contract change.
|
|
|
|
### 0.4.0 — LDAP sign-in by user bind (opt-in)
|
|
|
|
- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `EXAMPLE\{0}` — preferred, see 0.4.1 — or `{0}@example.com`) 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 (0.4.1 also canonicalises its case from the entry).
|
|
- 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 down-level (`EXAMPLE\{0}`, preferred) or UPN (`{0}@example.com`, only where every UPN prefix equals the `sAMAccountName`) 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:
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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
|