merge: API-key identity on the OPC UA data plane, keys+ACLs via the artifact (#520 stages 1-2, 4)
v2-ci / build (push) Successful in 4m13s
v2-ci / unit-tests (push) Failing after 15m0s

Replaces LDAP with API keys on the OPC UA data plane and gets those keys to
driver-only nodes, which since per-cluster mesh Phase 4 have no ConfigDb and so
denied every UserName token.

A client now presents a UserName token with the fixed username `apikey` and an
`otua_<keyId>_<secret>` password; ApiKeyOpcUaUserAuthenticator resolves it to a
principal with one indexed lookup plus one peppered HMAC and no network I/O --
which matters because the SDK raises ImpersonateUser synchronously under a
SessionManager-wide lock. LdapOpcUaUserAuthenticator is deleted along with the
four Security:Ldap keys that existed only to bound an inline LDAP bind on that
path. LDAP remains, for AdminUI login only.

Anonymous is a normal grantable principal rather than a bypass branch:
OpcUa:AllowAnonymous (default true, preserving today's behaviour) controls
admission, and an admitted anonymous session arrives at the role gates as the
same RoleCarryingUserIdentity a key produces.

ConfigComposer snapshots the ApiKey rows -- the peppered HMAC only, never a
secret, and the pepper is node config deliberately absent from the artifact --
and DeploymentArtifact.ParseDataPlaneAuthority decodes them node-side alongside
the cluster's NodeAcl rows. The consequence is asymmetric revocation: immediate
on a ConfigDb node, next-deployment on a driver-only one. That is documented
rather than hidden.

Two decisions worth keeping:

- The auth generation is a node-local counter DRIVEN BY the RevisionHash, not
  derived from its bits. PermissionTrieCache treats the highest GenerationId as
  current, so a hash-derived generation would install a lower number about half
  the time and the cache would go on serving the previous deployment's ACLs with
  nothing logged.
- The artifact feed runs before OpcUaPublishActor's plan.IsEmpty early-return.
  An artifact that only revokes a key changes no nodes, so feeding after that
  return would leave a revoked key working until some unrelated structural change
  happened to redeploy.

Node ACLs are still NOT enforced per node -- a key's grants collapse into the
existing server-wide WriteOperate and AlarmAck gates, and only Cluster-scoped
grants count, because honouring a narrower grant's flags while unable to honour
its scope would turn "WriteOperate on Equipment X" into server-wide write
authority. Read, Browse, HistoryRead and CreateMonitoredItems remain ungated.
The AdminUI carries "not enforced" banners saying so; stages 3, 5 and 6 remove
them.

Live-gated on the six-node docker-dev rig, 6/6 legs against driver-only
site-a-1, including both halves of the revocation asymmetry and confirmation
that a SITE-A grant does not leak to SITE-B.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 00:28:22 -04:00
69 changed files with 6999 additions and 1248 deletions
+51 -3
View File
@@ -274,7 +274,7 @@ central SQL Server.
> **Infra index:** [`infra/README.md`](infra/README.md) is the canonical index of all testing +
> deployment infrastructure — the Docker host layout, driver fixtures, the env-gated **live-gate**
> recipes (S7 blackhole, GLAuth outage, HistorianGateway LiveIntegration), the integration-test
> recipes (S7 blackhole, HistorianGateway LiveIntegration), the integration-test
> harness, the docker-dev rig, and the deploy setup. Start there; the sections below + `docs/v2/dev-environment.md` carry the detail.
> **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28).
@@ -591,9 +591,57 @@ Merged to master (`26fad75c`); **live gate PASSED** (`1281aebf` — driver nodes
Task 9 landed (`3a590a0c`). See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
`docs/plans/2026-07-22-per-cluster-mesh-program.md` §Phase 4.
## LDAP Authentication
## Authentication: API keys on the data plane, LDAP on AdminUI (#520)
The server uses LDAP-based user authentication via the `Security:Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server, and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapOpcUaUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`) implements `IOpcUaUserAuthenticator`, delegating the LDAP bind + group lookup to `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`, an `ILdapAuthService`). See `docs/security.md` for the full guide.
⚠️ **Changed 2026-07-31. The OPC UA data plane no longer uses LDAP at all.** It is machine-to-machine,
so a client presents an OPC UA **UserName** token with the fixed username **`apikey`** and an
`otua_<keyId>_<secret>` credential as the password. `ApiKeyOpcUaUserAuthenticator`
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/`) implements `IOpcUaUserAuthenticator`: one indexed lookup
on `keyId` plus one peppered HMAC-SHA256, `FixedTimeEquals`, **no network I/O**. The key resolves to a
**principal**; `NodeAcl.Principal` rows grant it permissions. `LdapOpcUaUserAuthenticator` is
**deleted**, along with four `Security:Ldap` keys that existed only for it (`AuthTimeoutMs`,
`MaxConcurrentAuthentications`, `OutageFailureThreshold`, `OutageCooldownSeconds`) — they bounded an
LDAP bind running inline in the SDK's synchronous, `SessionManager`-lock-holding impersonation
callback, and nothing on the remaining LDAP path has that property.
**LDAP remains, for AdminUI login only** (`Security:Ldap`, `OtOpcUaLdapAuthService` →
`ILdapAuthService`, group→role mapping via `GroupToRole` + `LdapGroupRoleMapping`). Do not "restore"
the data-plane authenticator when you see LDAP wiring in `Program.cs` — it is there for the AdminUI.
Two things to know before relying on any of this:
- ⚠️ **Node ACLs are still not enforced per node.** A key's grants collapse into the two existing
server-wide role gates (`WriteOperate` on Value writes, `AlarmAck` on Part 9 methods), and
**only `Cluster`-scoped grants count** — `ApiKeyInterimRoleMapper` deliberately under-grants, because
honouring a narrower grant's flags while unable to honour its *scope* would turn "WriteOperate on
Equipment X" into server-wide write authority. Read, Browse, HistoryRead and CreateMonitoredItems
remain ungated for any admitted session, **including Anonymous**.
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may
**not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather
than admitting with no roles.
- **Keys reach a driver-only node inside the deployment artifact** (stage 4). `ConfigComposer` snapshots
the `ApiKey` rows — the peppered HMAC only, never a secret, and the pepper is node config that is
deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them
node-side alongside the cluster's `NodeAcl` rows. `OpcUaPublishActor` installs both on
`IDataPlaneAuthoritySource` on every apply, **before** its `plan.IsEmpty` early-return, so a deployment
that only revokes a key still lands. A node with a ConfigDb keeps `ConfigDbApiKeyStore` (per-request
read); a driver-only node gets `ArtifactApiKeyStore`. ⚠️ **The consequence is asymmetric revocation:
immediate on a ConfigDb node, next-deployment on a driver-only one.**
- ⚠️ **The auth generation is a node-local COUNTER driven by the `RevisionHash`, not derived from its
bits.** `PermissionTrieCache` treats the highest `GenerationId` as current, so a generation taken from
hash bytes would install a *lower* number about half the time and the cache would go on serving the
previous deployment's ACLs with nothing logged. Pinned by
`DataPlaneAuthorityStateTests.The_generation_only_ever_increases_even_when_the_hash_decreases`.
- Note `OtOpcUaServerHostedService` takes `IOpcUaUserAuthenticator` **and** `IDataPlaneAuthoritySource`
as **required** ctor parameters — the built-in DI container does not fall back to a default value for
an unregistered service, so both must be registered explicitly on every node (deny-all / empty on a
node with neither role) or it does not boot.
Remaining stages, and the rationale for every decision above, are in
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide.
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.)
+14 -6
View File
@@ -137,6 +137,14 @@ x-secrets-env: &secrets-env
# AllowInsecure=false ⇒ OptionsValidationException), so a node that carries no LDAP config crashes at boot.
# This anchor is merged into every host node's environment (via `<<: [*secrets-env, *ldap-env]`). Central
# still also lists the block inline for readability; the explicit keys win over the merge, same values.
# #520 — OPC UA data-plane API keys. The pepper is merged into every host node: a driver node needs it
# to VERIFY keys, an admin node to MINT them, and a mismatch between the two is a silent authentication
# failure. Committed dev value only, under the same committed-dev-secret exception as the LDAP service
# account and the deploy key below it; a real deployment supplies Security__ApiKeys__Pepper via env or
# a ${secret:} reference and NEVER commits it. Rotating it invalidates every existing key.
x-apikeys-env: &apikeys-env
Security__ApiKeys__Pepper: "docker-dev-apikey-pepper-not-for-production"
x-ldap-env: &ldap-env
Security__Ldap__Enabled: "true"
Security__Ldap__DevStubMode: "false"
@@ -268,7 +276,7 @@ services:
sql: { condition: service_healthy }
migrator: { condition: service_completed_successfully }
environment:
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
@@ -393,7 +401,7 @@ services:
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
environment:
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
@@ -548,7 +556,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
# mem_reservation are inherited from the *otopcua-host anchor.
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
@@ -649,7 +657,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
@@ -737,7 +745,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
@@ -809,7 +817,7 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
<<: [*secrets-env, *ldap-env]
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
@@ -0,0 +1,192 @@
# API-key identity + node ACL enforcement (Gitea #520)
**Status:** design agreed 2026-07-31, not yet implemented.
**Supersedes:** `docs/v2/acl-design.md` (LDAP-group-keyed model, never wired).
## Why this exists
`IPermissionEvaluator` + `TriePermissionEvaluator` + `PermissionTrieCache` + `PermissionTrieBuilder`
have lived in `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` with 30 passing unit tests and **zero
production call sites** since v2. Operators can author `NodeAcl` rows at `/clusters/{id}/acls`;
`ConfigComposer` snapshots every row into every deployment artifact, so an ACL edit shifts the
artifact `RevisionHash` and ships fleet-wide — and `DeploymentArtifact` never deserializes them. The
bytes arrive and are dropped.
Actual enforcement today is two server-wide role checks: `WriteOperate` on Value writes (either
realm) and `AlarmAck` on the five Part 9 alarm methods. **Read, Browse, HistoryRead and
CreateMonitoredItems have no authorization check at all**, so any admitted session — including
Anonymous — reads the entire address space.
Re-verified on `master` 2026-07-31 (all six claims still true). Both AdminUI warning banners and the
corrected docs from 2026-07-27 are in place, so the trap is signposted but still a trap.
## Decisions taken (owner, 2026-07-31)
| # | Decision | Consequence |
|---|---|---|
| D1 | **The OPC UA data plane is machine-to-machine.** Identity is an **API key** presented with a static username, not an LDAP user. | `NodeAcl` keys on a principal, not an LDAP group — blocker #2 dissolves rather than being solved. |
| D2 | **Replace LDAP entirely on the OPC UA data plane.** | `LdapOpcUaUserAuthenticator` and the OPC UA half of `Security:Ldap` are deleted. **AdminUI login keeps its own LDAP** — untouched. |
| D3 | **Anonymous is a normal, grantable principal**, gated by a new enable setting. | When enabled, Anonymous resolves to the reserved principal `anonymous` and can be granted read *or* write like any key. When disabled, anonymous session activation is rejected. |
D3 is the neat one: it removes Anonymous as a special case in the evaluator. There is no
"anonymous bypass" branch to get wrong — an ungranted anonymous session is denied by the same
fail-closed path as an ungranted key.
## Assumption I am making explicit (not asked)
**Enforcement ships behind `Security:Acl:Enabled`, default `false`.** This is the dark-switch
discipline `MeshTransport` / `ConfigSource` / `Telemetry` all shipped under: both sides are wired in
both modes, the flip is a config change rather than a redeploy. Without it, the first deploy carrying
this code would silently start denying every client that has no key — including **ScadaBridge's Data
Connection Layer, which consumes this address space from another repo**. The authenticator swap (D2)
is *not* dark-switchable in the same way and is a hard cutover; that asymmetry is deliberate and
called out in the rollout stage.
## Identity model
```
OPC UA UserName token Anonymous token
username = <static> (no credential)
password = <api key secret>
| |
v v
ApiKeyOpcUaUserAuthenticator Security:Acl:AllowAnonymous ?
| |
v v
principal set <------------- "anonymous"
|
v
PermissionTrie (per cluster, per artifact revision)
```
- **Key format** mirrors the family convention (`histgw_<id>_<secret>` in HistorianGateway,
`apikey create-key` in mxaccessgw): `otua_<keyId>_<secret>`. The `keyId` prefix makes lookup an
index hit rather than a scan over every stored hash.
- **Storage is a hash, never the secret** — HMAC-SHA256 under a server pepper, compared with
`CryptographicOperations.FixedTimeEquals`. The plaintext is shown **once** at creation in AdminUI
and is unrecoverable afterwards.
- **A key maps to one or more named principals**, and `NodeAcl` rows key on the principal name. The
indirection is deliberate: many keys (one per consuming system, independently revocable) share one
grant set, and rotating a key never touches an ACL row.
### `NodeAcl.LdapGroup` → `NodeAcl.Principal`
A rename + migration, not a new column. The trie walker is unchanged — it already matches an opaque
string; only what produces that string changes.
## The four blockers, re-scoped
| # | #520's blocker | Resolution under this design |
|---|---|---|
| 1 | Raw-realm nodes have no representable `NodeScope` (`NodeHierarchyKind` has one member, `Equipment`) | **Add `NodeHierarchyKind.Raw`**, whose path is the RawPath segments `Folder/Driver/Device/TagGroup/Tag`. `NodeAclScopeKind` already carries `Namespace` and `FolderSegment` — `FolderSegment` becomes matchable for the first time, at raw-folder level. This is the one genuine **design** item, not wiring. |
| 2 | Session carries roles, not LDAP groups | **Dissolved by D1** — the principal comes from the key, not from a directory. |
| 3 | `PermissionTrieBuilder`'s `scopePaths` has no producer | Built node-side from the artifact's UNS hierarchy + raw tree. Note every existing evaluator unit test runs in the no-`scopePaths` "deterministic test mode", so **the production hierarchy path is currently untested** — it needs its own coverage, not just a producer. |
| 4 | No session lifecycle / `AuthGenerationId` | Stamp the generation from the artifact `RevisionHash` at session activation; a new deploy invalidates cached decisions. `UserAuthorizationState` already has the freshness predicates. |
Plus, from the same audit: the node never parses ACLs out of the artifact, and `OtOpcUaNodeManager`
has no cluster identity (`NodeScope.ClusterId` is `required` and the evaluator fails closed on
mismatch).
## Enforcement seams
| Operation | Seam today | Work |
|---|---|---|
| Write (Value) | `EvaluateEquipmentWriteGate` — pure, fail-closed, unit-testable | Swap the role check for an ACL decision. Runs under the node-manager `Lock`, so the check must stay non-blocking — the evaluator is pure, which fits. |
| Alarm ack/confirm/shelve | two `AlarmAck` role gates | Same swap. |
| HistoryRead | `HistoryReadRawModified` / `Processed` / `AtTime` / `Events` overrides **already exist** | Add the check inside them. |
| **Read / Browse / Subscribe** | **none** | The real gap. Needs a `Read` override or per-variable `OnSimpleReadValue`, on the hot publish path — so the decision must be cached per (session, node), not evaluated per value. |
## Smaller inconsistencies to settle en route
- `NodeAclScopeKind.FolderSegment` is offered in the authoring dropdown but the trie walker has no
level to match it at — fixed by blocker #1's `Raw` kind.
- `NodePermissions.AlarmRead` has no `OpcUaOperation` mapping — grantable but unreachable.
- `NodeAcl.ScopeId` has no FK and no existence check, so scope ids dangle silently.
- `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all — the evaluator is not even
on the node manager's compile-time reference graph.
- `NodePermissions.HistoryUpdate` is deliberately in no bundle until HistoryUpdate exists. Leave it.
## Stages
Each stage is one commit, builds clean, and leaves the tree shippable.
1. ✅ **Foundation — principal model.** `NodeAcl.LdapGroup` → `Principal` (+ migration); `ApiKey` entity
+ store + hashing; `OpcUaServer.csproj` references `Core`. No behaviour change.
2. ✅ **Identity cutover.** `ApiKeyOpcUaUserAuthenticator` replaces `LdapOpcUaUserAuthenticator` on the
OPC UA data plane; Anonymous resolves to the `anonymous` principal or is rejected per setting.
AdminUI LDAP untouched.
3. **Raw scope.** *(not started — sequenced after stage 4 at the owner's request)* `NodeHierarchyKind.Raw` + RawPath scope paths; `scopePaths` producer built from the
artifact; coverage for the production hierarchy path that today has none.
4. **Artifact + lifecycle.** ✅ **Done.** `ConfigComposer` snapshots `ApiKeys` alongside the `NodeAcls`
it already carried; `DeploymentArtifact.ParseDataPlaneAuthority` decodes both node-side (grants
scoped to the node's own cluster, keys not — a key is a fleet-wide credential and the ACL rows are
what bind it); `OpcUaPublishActor` installs them on `DataPlaneAuthorityState` on every apply;
`ArtifactApiKeyStore` serves them to session activation on a driver-only node, which until now
denied every UserName token; and `OtOpcUaNodeManager` gains a `DataPlaneAuthority` property, which
is the cluster identity blocker #4 named.
Two things went differently from the sketch, both deliberately:
- **The generation is a counter, not the hash.** "Stamp the generation from the `RevisionHash`"
reads as "derive a long from the hash bytes", and that is wrong: `PermissionTrieCache` treats the
highest `GenerationId` as current (`ClusterEntry.WithAdditional`) and `Prune` keeps the highest,
while hash fragments are unordered — so roughly half of all deploys would install a *lower*
generation and the cache would keep serving the previous deployment's ACLs, silently. The hash
still *drives* the stamp (a new hash advances it, a re-applied one does not); it just does not
*become* it.
- **Sessions are not stamped yet.** Building a `UserAuthorizationState` per session belongs with the
first seam that reads one, in stage 5. Constructing one now would be a per-session object nothing
consults — the dormant-wiring failure mode this repo has been bitten by before.
Also settled here: the feed runs **before** `OpcUaPublishActor`'s `plan.IsEmpty` early-return. An
artifact that only revokes a key produces an empty address-space plan, so feeding after that return
would leave the revoked key working until some unrelated structural change happened to redeploy.
5. **Enforce write + alarms + HistoryRead** behind `Security:Acl:Enabled`, using the seams that
already exist. Includes building the per-session `UserAuthorizationState` and stamping it with the
generation stage 4 now supplies, plus widening both `IApiKeyStore` implementations from
Cluster-scoped grants to the full grant set (they narrow today because
`ApiKeyInterimRoleMapper` cannot honour scope).
6. **Enforce read/browse/subscribe** — the new seam, with per-session decision caching.
7. **AdminUI.** Key management (create/revoke, show-once) ✅ **done early, in stage 4's predecessor**; ACL authoring against principals, and
**remove the two "not enforced" warning banners** — which is the honest signal that this is done.
8. **Rollout.** Docs, the ScadaBridge key hand-off, and flipping `Security:Acl:Enabled` on docker-dev
as the live gate.
## Stage 4 live gate — PASSED (docker-dev, 2026-07-31)
Run against the six-node docker-dev rig, with **site-a-1** as the subject: `Cluster:Roles =
driver,cluster-SITE-A`, no `ConnectionStrings:ConfigDb`, `ConfigSource:Mode=FetchAndCache`. Before this
stage that node registered `NullOpcUaUserAuthenticator` and denied every UserName token.
The key was minted with an **independent Python HMAC** rather than by the C# minter, so the verifier was
checked against something other than itself.
| # | Leg | Result |
|---|---|---|
| 1 | Boot from LocalDb cache installs an authority generation | `generation 1 installed (cluster=SITE-A, revision=3f4a908c, keys=0, grants=0)` — cluster identity resolved with no ConfigDb |
| 2 | Deploy a key + a SITE-A cluster grant | `generation 2 installed (revision=cb165478, keys=1, grants=1)`; revision matches the deploy response |
| 3 | The key authenticates **on the driver-only node** | `ApiKeyAuth: granted key Stage4 Live Gate (a1b2c3d4e5f60011) as principal stage4-gate with roles [WriteOperate]` |
| 4 | A wrong secret is denied there | `denied — secret mismatch for key id a1b2c3d4e5f60011` |
| 5 | **Revocation asymmetry, both halves** | `Enabled=0` in SQL, no deploy: central-1 denies on the next request (`no enabled, unexpired key…`) while site-a-1 still accepts. After deploying: `generation 3`, and site-a-1 denies. This is the documented sharp edge, observed rather than asserted. |
| 6 | Grants do not leak across clusters | site-b-1 installed `keys=1, grants=0` from the same artifact — it received the key but not SITE-A's grant |
Rig left clean (0 keys, 0 ACL rows, redeployed, all six nodes healthy).
## Live gate (stage 8)
On docker-dev, with `Security:Acl:Enabled=true`:
1. A key granted `ReadOnly` on one equipment reads its tags and is denied a tag outside the grant.
2. The same key is denied a write; a key granted `Operator` writes successfully.
3. Anonymous with the setting off cannot activate a session; with it on and a `ReadOnly` grant, it
reads and is denied writes.
4. Revoking a key denies the next request **after a deploy** — see the caveat below.
5. A raw-realm grant (`NodeHierarchyKind.Raw`) and a UNS grant both evaluate, proving blocker #1.
## Known consequence to design around
**Driver-only nodes have no ConfigDb (Phase 4).** Keys and ACLs must therefore reach them inside the
**deployment artifact**, exactly as `NodeAcl` rows already do. That means **key revocation requires a
deploy** — there is no out-of-band revocation channel to a site node. This is a real operational
sharp edge and must be documented rather than discovered; if immediate revocation is needed later,
the Phase 5 gRPC telemetry stream is the existing node-reachable channel to carry it.
+111 -18
View File
@@ -113,10 +113,22 @@ The Server accepts three OPC UA identity-token types:
| Token | Handler | Notes |
|---|---|---|
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. |
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. |
| UserName/Password | `ApiKeyOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by an `IApiKeyStore`. | The username is the literal `apikey` and the password is an `otua_<keyId>_<secret>` credential (#520). One indexed lookup plus one peppered HMAC-SHA256, no network I/O. The key resolves to a **principal**, whose `NodeAcl` grants become roles on a `RoleCarryingUserIdentity`. **LDAP is not on this path** — it backs AdminUI login only. |
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. |
When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`.
When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`;
the Host wires the real `ApiKeyOpcUaUserAuthenticator` as a singleton in `Program.cs`. Which
**`IApiKeyStore`** backs it depends on the node's role:
| Node | Store | Revocation takes effect |
|---|---|---|
| `admin` (including fused `admin,driver`) | `ConfigDbApiKeyStore` — reads the config database per request | On the **next request** |
| `driver`-only | `ArtifactApiKeyStore` — reads the keys carried by the deployment artifact, held in memory | On the **next deployment** |
| neither role | `NullOpcUaUserAuthenticator` — denies every UserName token | n/a |
The deny-all is registered **explicitly**, not left absent: `OtOpcUaServerHostedService` takes
`IOpcUaUserAuthenticator` as a required constructor parameter, so an unregistered seam fails DI and the
node does not boot at all.
### LDAP bind flow (`OtOpcUaLdapAuthService`)
@@ -162,19 +174,90 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s
`GroupToRole` maps LDAP group names → roles (case-insensitive); a user gets every role whose source group is in their membership. The values are the canonical control-plane role strings (`Viewer` / `Designer` / `Administrator`, plus the appsettings-only `Operator` for the `DriverOperator` policy); the same map also supplies data-plane role strings (`ReadOnly`, `WriteOperate`, `WriteTune`, `WriteConfigure`, `AlarmAck`) — see [Role grant source (data-plane)](#role-grant-source-data-plane) below. `UserNameAttribute: "sAMAccountName"` is the critical AD override — the GLAuth dev default is `cn`, which is not how AD users are looked up; use `userPrincipalName` instead if operators log in with `user@corp.example.com` form. `LdapOptionsValidator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs`) fails startup when `Transport = None` and `AllowInsecure = false` on a real-LDAP (non-DevStub) config.
### LDAP resilience — timeout, concurrency, outage circuit (arch-review 03/S2)
### API-key identity (OPC UA data plane)
The OPC UA SDK raises its impersonation (UserName) callback **synchronously**, inside a `SessionManager`-wide event lock (verified against `OPCFoundation.NetStandard.Opc.Ua.Server` 1.5.378). A slow or hung LDAP bind inside that callback therefore does not stall one SDK thread — it **serializes every session activation server-wide** (Anonymous and X509 included). Since the callback is irreducibly synchronous, `LdapOpcUaUserAuthenticator` bounds it at its own boundary with five `Security:Ldap` keys (all validated at startup by `LdapOptionsValidator` when `Enabled && !DevStubMode`):
The OPC UA data plane is **machine-to-machine**, so a consuming system authenticates with an API key
rather than an LDAP user (Gitea #520). The client presents an OPC UA **UserName** token whose username
is the fixed literal `apikey` and whose password is the key:
```
otua_<keyId>_<secret>
```
`ApiKeyOpcUaUserAuthenticator` verifies it — one indexed lookup on `keyId` plus one peppered
HMAC-SHA256 over the secret, compared with `CryptographicOperations.FixedTimeEquals`. No network I/O.
The key resolves to a **principal**, and `NodeAcl` rows naming that principal are what grant it
permissions.
| Key | Notes |
|---|---|
| `Security:ApiKeys:Username` | The static username clients present. Default `apikey`. Checked rather than ignored, so a typo is an explicit denial. |
| `Security:ApiKeys:Pepper` | Server-side pepper for the stored hashes. **Supply via `Security__ApiKeys__Pepper` or `${secret:}` — never commit it.** Rotating it invalidates every existing key. Has no default: an empty pepper still hashes "successfully", so `ApiKeySecret.ComputeHash` throws on one rather than producing a working-looking deployment with no protection. |
Secrets are never stored — only the hash. The plaintext is shown **once** at creation and is
unrecoverable afterwards.
**Every rejection returns the same opaque message** ("Invalid API key"), with the real reason logged
server-side only, so the server cannot be used as an oracle for whether a key id exists or which half
of a credential was wrong.
⚠️ **Revocation requires a deploy.** Per-cluster mesh Phase 4 leaves driver-only nodes with no
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
`Enabled` takes effect on a node only once a new artifact reaches it.
### Anonymous is a normal principal
Anonymous sessions are **admitted by default** (`OpcUa:AllowAnonymous`, default `true` — flipping the
default would silently deny every client that has no key the moment this code deploys, including
ScadaBridge). Set it to `false` to make keys mandatory: the Anonymous token policy is then not
advertised, so a client is refused at *discovery* rather than only at activation, and anonymous
activation is rejected independently in case a client attempts a token type the endpoint never offered.
When admitted, an anonymous session is **not a special case** — it authenticates as the reserved
principal `anonymous`, and `NodeAcl` rows naming it grant permissions exactly as they would a key's
principal. It can therefore be granted **write**, not merely tolerated as read-only. It arrives at the
downstream gates as the same `RoleCarryingUserIdentity` a key produces, so no gate carries an
anonymous-shaped branch and there is no bypass path to get wrong.
Two consequences worth knowing:
- `anonymous` is **reserved**: an API key may not use it as its principal, or a credentialled client
would silently inherit whatever anonymous was granted. Rejected at authentication as well as at
authoring.
- If the anonymous principal's grants **cannot be read**, the session is **denied**, not admitted with
no roles. Admitting is the pre-#520 behaviour, and defaulting to it precisely when the server cannot
determine what anonymous may do stops being harmless once reads are gated.
⚠️ **Node ACLs are still not enforced per node.** Until the enforcement stages land, a key's grants are
collapsed into the two existing server-wide role gates, and **only `Cluster`-scoped grants count** —
a narrower grant confers nothing rather than silently widening to server-wide authority. Reads,
browses, subscriptions and history remain ungated for any admitted session. See
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`.
### LDAP resilience — mostly retired with the data-plane authenticator (#520)
> **Changed 2026-07-31.** The OPC UA **data plane** no longer uses LDAP at all — it authenticates with
> API keys (see [API-key identity](#api-key-identity-opc-ua-data-plane)). `LdapOpcUaUserAuthenticator`
> is deleted, and with it four of the five `Security:Ldap` resilience keys: **`AuthTimeoutMs`**,
> **`MaxConcurrentAuthentications`**, **`OutageFailureThreshold`** and **`OutageCooldownSeconds`**.
> Setting them now does nothing (options binding ignores unknown keys); remove them from any config
> that carries them.
Those four existed for one specific reason. The OPC UA SDK raises its impersonation callback
**synchronously**, inside a `SessionManager`-wide event lock (verified against
`OPCFoundation.NetStandard.Opc.Ua.Server` 1.5.378), so a slow or hung LDAP bind in that callback did
not stall one SDK thread — it **serialized every session activation server-wide**, Anonymous and X509
included. That constraint is gone: key verification is one indexed lookup plus one HMAC, with no
network I/O on the path.
LDAP now runs only on **AdminUI login**, an ordinary async request path where a slow directory affects
the one request making it. One key remains:
| Key | Default | Bounds |
|---|---|---|
| `ConnectionTimeoutMs` | `10000` | Projected into the shared library's `ConnectionTimeoutMs` — bounds the socket connect **and** each per-operation search. First line of defence against an outage. |
| `AuthTimeoutMs` | `15000` | Hard whole-flow wall-clock bound (bind **and** role-map) enforced at the authenticator boundary; a slow/hung flow denies **"Authentication timed out"** (fail-closed) and releases the SDK thread. Deliberately `> ConnectionTimeoutMs` so a healthy-but-slow flow is not cut off. |
| `MaxConcurrentAuthentications` | `8` | Max in-flight authentications; excess denies **"Authentication backend busy"** (local backpressure — does **not** feed the circuit). Bounds accumulation of orphaned/timed-out binds during an outage. |
| `OutageFailureThreshold` | `3` | Consecutive **system-side** failures (boundary timeout, directory-unreachable, or unexpected throw) that open the outage circuit. Any success or user-credential deny resets the streak. `0` disables the circuit. |
| `OutageCooldownSeconds` | `15` | How long the open circuit fast-denies **"Authentication backend unavailable"** without touching LDAP; after cooldown the next call half-open-probes, and one more system failure re-opens immediately. |
| `ConnectionTimeoutMs` | `10000` | Projected into the shared library's `ConnectionTimeoutMs` — bounds the socket connect **and** each per-operation search. Validated `> 0` at startup by `LdapOptionsValidator`. |
Defaults are behaviour-neutral on a healthy directory. All new paths are **fail-closed** (they deny). A user-credential deny (wrong password) is fast when the directory is up and never trips the circuit; only a directory *outage* opens it.
---
@@ -240,11 +323,17 @@ What is real about it:
validated, versioned and persisted.
- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL
edit shifts the artifact's `RevisionHash` and ships to every node.
- The node side **never deserializes them** — `DeploymentArtifact` has no ACL reader. The bytes arrive
and are dropped.
- The node side **does** deserialize them as of #520 stage 4 —
`DeploymentArtifact.ParseDataPlaneAuthority` reads the artifact's `NodeAcls` (scoped to the node's own
cluster) and `ApiKeys`, and `OpcUaPublishActor` installs both on the node's
`IDataPlaneAuthoritySource` on every apply. The node manager holds that source, so it finally has a
cluster identity.
- **Nothing evaluates them yet.** The grants are decoded and available; the enforcement seams that would
consult them land in stages 5 and 6.
So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour
to expect until the tracking issue below is closed.
So an operator can still author a grant, deploy it green, and have it change nothing at the node — but
the grant now reaches the node rather than being dropped at its door. That is the behaviour to expect
until the tracking issue below is closed.
**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"):
@@ -252,14 +341,18 @@ to expect until the tracking issue below is closed.
`Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot
express — yet the write gate is attached to raw tags. Roughly half the writable address space has
no scope to evaluate. This is a design decision, not wiring.
2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the
session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list
is discarded inside `LdapOpcUaUserAuthenticator`.
2. ~~**The identity carries roles, not groups.**~~ **Dissolved by #520 stage 2.** The column is now
`NodeAcl.Principal` and the principal comes from the presented API key (or the reserved `anonymous`),
not from a directory — so there is no group list to lose.
3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster
grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have
to be derived from the artifact's UNS relations.
4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but
nothing in production constructs one, stamps a generation, or drives a refresh.
nothing in production constructs one per session. **Half-closed by stage 4:** the *generation* now
exists and advances — `DataPlaneAuthorityState` stamps a monotonic `GenerationId` each time an
artifact with a new `RevisionHash` is applied — so there is something to stamp a session with. Doing
the stamping is stage 5's job, because until an enforcement seam reads it, a per-session state object
would be constructed and never consulted.
Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in
the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead`
+46 -6
View File
@@ -472,13 +472,19 @@ All five are exposed as **OPC UA properties** on the equipment node so external
### `NodeAcl`
> ⚠️ **Pre-existing drift, noted 2026-07-31 and NOT fixed here:** the block below still shows the
> `GenerationId` column and its `ConfigGeneration` FK. v3 made the config tables single live-state and
> dropped generation versioning (see `sp_ComputeGenerationDiff`, which now ignores its generation
> parameters), so the real table has no such column and the index names differ accordingly. Only the
> `LdapGroup` → `Principal` rename below is current.
```sql
CREATE TABLE dbo.NodeAcl (
NodeAclRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
NodeAclId nvarchar(64) NOT NULL, -- stable logical ID across generations
ClusterId nvarchar(64) NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
LdapGroup nvarchar(256) NOT NULL,
Principal nvarchar(256) NOT NULL, -- API-key principal, or the reserved 'anonymous' (#520)
ScopeKind nvarchar(16) NOT NULL CHECK (ScopeKind IN ('Cluster', 'Namespace', 'UnsArea', 'UnsLine', 'Equipment', 'Tag')),
ScopeId nvarchar(64) NULL, -- NULL when ScopeKind='Cluster'; logical ID otherwise
PermissionFlags int NOT NULL, -- bitmask of NodePermissions enum
@@ -487,17 +493,51 @@ CREATE TABLE dbo.NodeAcl (
CREATE INDEX IX_NodeAcl_Generation_Cluster
ON dbo.NodeAcl (GenerationId, ClusterId);
CREATE INDEX IX_NodeAcl_Generation_Group
ON dbo.NodeAcl (GenerationId, LdapGroup);
CREATE INDEX IX_NodeAcl_Generation_Principal
ON dbo.NodeAcl (GenerationId, Principal);
CREATE INDEX IX_NodeAcl_Generation_Scope
ON dbo.NodeAcl (GenerationId, ScopeKind, ScopeId) WHERE ScopeId IS NOT NULL;
CREATE UNIQUE INDEX UX_NodeAcl_Generation_LogicalId
ON dbo.NodeAcl (GenerationId, NodeAclId);
-- Within a generation, a (Group, Scope) pair has at most one row
CREATE UNIQUE INDEX UX_NodeAcl_Generation_GroupScope
ON dbo.NodeAcl (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId);
-- Within a generation, a (Principal, Scope) pair has at most one row
CREATE UNIQUE INDEX UX_NodeAcl_Generation_PrincipalScope
ON dbo.NodeAcl (GenerationId, ClusterId, Principal, ScopeKind, ScopeId);
```
### `ApiKey`
OPC UA data-plane credentials (#520). The data plane is machine-to-machine, so a consuming system
authenticates with a key rather than an LDAP user; the key resolves to a `Principal` that `NodeAcl`
rows grant permissions to. One principal per key — two keys may name the same principal, which is
what lets a key be rotated without touching an ACL row.
```sql
CREATE TABLE dbo.ApiKey (
ApiKeyRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
ApiKeyId nvarchar(64) NOT NULL, -- public id; middle segment of otua_<keyId>_<secret>
Name nvarchar(128) NOT NULL, -- human label, e.g. the consuming system
SecretHash nvarchar(128) NOT NULL, -- base64 peppered HMAC-SHA256; NEVER the secret
Principal nvarchar(256) NOT NULL,
Enabled bit NOT NULL,
CreatedUtc datetime2 NOT NULL,
ExpiresUtc datetime2 NULL,
Notes nvarchar(512) NULL,
RowVersion rowversion NOT NULL
);
-- The presented key carries ApiKeyId in the clear precisely so verification is this seek.
CREATE UNIQUE INDEX UX_ApiKey_ApiKeyId ON dbo.ApiKey (ApiKeyId);
CREATE INDEX IX_ApiKey_Principal ON dbo.ApiKey (Principal);
```
`ApiKeyId` is `NOT NULL` here, deliberately unlike the nullable `NodeAclId` precedent: that shape
permits two NULL ids under a filtered unique index, which is acceptable for a config row and not for
a credentials lookup key.
⚠️ **Revocation requires a deploy.** Per-cluster mesh Phase 4 leaves driver-only nodes with no
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
`Enabled` takes effect on a node only once a new artifact reaches it.
`NodeAcl` is **generation-versioned** (decision #130). ACL changes go through draft → diff → publish → rollback like every other content table. Cross-generation invariant: `NodeAclId` once published with `(LdapGroup, ScopeKind, ScopeId)` cannot have any of those columns change in a future generation; rename an LDAP group by disabling the old grant and creating a new one.
`PermissionFlags` is a bitmask of the `NodePermissions` enum defined in `acl-design.md` (Browse, Read, Subscribe, HistoryRead, WriteOperate, WriteTune, WriteConfigure, AlarmRead, AlarmAcknowledge, AlarmConfirm, AlarmShelve, MethodCall). Common bundles (`ReadOnly`, `Operator`, `Engineer`, `Admin`) expand to specific flag combinations at evaluation time.
+9 -10
View File
@@ -135,17 +135,16 @@ export S7_TIMEOUT_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otop
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~ConnectTimeoutOutage"
```
### 5b. LDAP GLAuth outage/recovery (`LdapGlauthOutageLiveTests`)
Drives the directory-outage circuit through the production auth path against the harness GLAuth
(§4), paused/unpaused. Also verifies the GLAuth swap binds correctly.
### 5b. LDAP GLAuth outage/recovery — **RETIRED 2026-07-31 (#520)**
```bash
ssh dohertj2@10.100.0.35 'cd ~/otopcua-harness && docker compose up -d ldap'
export GLAUTH_LIVE_HOST=10.100.0.35 # port defaults to 3894 (GLAUTH_LIVE_PORT)
export GLAUTH_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-harness-ldap-1"'
export GLAUTH_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-harness-ldap-1"'
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapGlauthOutageLive"
```
`LdapGlauthOutageLiveTests` and the directory-outage circuit it drove were deleted with
`LdapOpcUaUserAuthenticator`. The OPC UA data plane authenticates with **API keys**, not LDAP, so no
LDAP bind runs inside the SDK's synchronous impersonation callback any more — which is the only
reason that circuit, its wall-clock bound, and its concurrency cap existed. LDAP remains only on the
AdminUI login path, where a slow directory stalls one request rather than serializing every OPC UA
session activation.
The harness GLAuth (§4) is still used by `OTOPCUA_HARNESS_USE_LDAP=1` and is unaffected.
### 5c. HistorianGateway LiveIntegration soak (`Category=LiveIntegration`) — needs VPN
Exercises the gateway read/write/alarm/EnsureTags paths against a **real AVEVA historian**. Requires
@@ -0,0 +1,67 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine (#520), so a
/// consuming system authenticates with a key rather than an LDAP user, and the key resolves to a
/// <see cref="Principal"/> that <see cref="NodeAcl"/> rows grant permissions to.
/// </summary>
/// <remarks>
/// <para>
/// <b>One principal per key, deliberately.</b> The reason to separate keys from principals is so
/// that many keys can share one grant set and rotating a key never touches an ACL row — and that
/// holds with a single principal per key, because nothing stops two keys naming the same
/// principal. A key belonging to several principals at once (union of grants) buys little and
/// costs a join table, so it is not modelled. <c>UserAuthorizationState.Principals</c> is still a
/// list, so adding it later needs no change on the ACL side.
/// </para>
/// <para>
/// <b>The secret is never stored.</b> <see cref="SecretHash"/> holds a peppered HMAC-SHA256 of the
/// secret half only; the plaintext is shown once at creation and is unrecoverable afterwards.
/// </para>
/// <para>
/// ⚠️ <b>Revocation requires a deploy.</b> Phase 4 leaves driver-only nodes with no ConfigDb, so
/// keys reach them inside the deployment artifact exactly as <see cref="NodeAcl"/> rows do.
/// Setting <see cref="Enabled"/> to false takes effect on a node only once a new artifact reaches
/// it. This is an operational sharp edge, not an oversight — see the #520 design doc.
/// </para>
/// </remarks>
public sealed class ApiKey
{
/// <summary>Gets or sets the database row ID for this key.</summary>
public Guid ApiKeyRowId { get; set; }
/// <summary>
/// Gets or sets the public key id — the middle segment of <c>otua_&lt;keyId&gt;_&lt;secret&gt;</c>.
/// Carried in the clear so verification is an index seek rather than a scan over every stored hash.
/// </summary>
public required string ApiKeyId { get; set; }
/// <summary>Gets or sets the human-readable label, e.g. the consuming system's name.</summary>
public required string Name { get; set; }
/// <summary>
/// Gets or sets the base64 peppered HMAC-SHA256 of the key's secret segment. Never the secret.
/// </summary>
public required string SecretHash { get; set; }
/// <summary>
/// Gets or sets the principal this key authenticates as. <see cref="NodeAcl.Principal"/> rows
/// naming this value grant it permissions.
/// </summary>
public required string Principal { get; set; }
/// <summary>Gets or sets whether the key may authenticate. See the revocation caveat on the type.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Gets or sets when the key was created (UTC).</summary>
public DateTime CreatedUtc { get; set; }
/// <summary>Gets or sets when the key expires (UTC); null never expires.</summary>
public DateTime? ExpiresUtc { get; set; }
/// <summary>Gets or sets optional operator notes.</summary>
public string? Notes { get; set; }
/// <summary>Optimistic concurrency token, matching the rest of the live-edit config model.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
/// One ACL grant: a principal gets a set of <see cref="NodePermissions"/> at a specific scope.
/// Generation-versioned per decision. See <c>acl-design.md</c> for evaluation algorithm.
/// </summary>
public sealed class NodeAcl
@@ -17,8 +17,8 @@ public sealed class NodeAcl
/// <summary>Gets or sets the cluster ID for this ACL entry.</summary>
public required string ClusterId { get; set; }
/// <summary>Gets or sets the LDAP group for this ACL entry.</summary>
public required string LdapGroup { get; set; }
/// <summary>Gets or sets the principal (API-key principal name, or the reserved <c>anonymous</c>) this ACL entry grants to.</summary>
public required string Principal { get; set; }
/// <summary>Gets or sets the scope kind for this ACL entry.</summary>
public required NodeAclScopeKind ScopeKind { get; set; }
@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// #520 — <c>NodeAcl.LdapGroup</c> becomes <c>NodeAcl.Principal</c>. The OPC UA data plane is
/// machine-to-machine, so an ACL grant keys on an API-key principal (or the reserved
/// <c>anonymous</c>), not on an LDAP group. EF scaffolds this as RenameColumn/RenameIndex, so
/// existing rows survive.
/// </summary>
/// <remarks>
/// ⚠️ The column is also read by <c>dbo.sp_ComputeGenerationDiff</c>, created in
/// <c>V3Initial.StoredProcedures</c> and never redefined since. A bare column rename leaves that
/// proc referencing a column that no longer exists — SQL Server does not validate a procedure
/// body at rename time, so the object keeps reporting healthy and fails at CALL time with
/// <c>Invalid column name 'LdapGroup'</c>, silently breaking config-drift detection. The proc is
/// therefore re-emitted below in the same migration, body otherwise byte-identical. If you add or
/// rename a column on any table that proc checksums, it needs the same treatment.
/// </remarks>
public partial class RenameNodeAclLdapGroupToPrincipal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "LdapGroup",
table: "NodeAcl",
newName: "Principal");
migrationBuilder.RenameIndex(
name: "UX_NodeAcl_GroupScope",
table: "NodeAcl",
newName: "UX_NodeAcl_PrincipalScope");
migrationBuilder.RenameIndex(
name: "IX_NodeAcl_Group",
table: "NodeAcl",
newName: "IX_NodeAcl_Principal");
migrationBuilder.Sql(ComputeGenerationDiff("Principal"));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Principal",
table: "NodeAcl",
newName: "LdapGroup");
migrationBuilder.RenameIndex(
name: "UX_NodeAcl_PrincipalScope",
table: "NodeAcl",
newName: "UX_NodeAcl_GroupScope");
migrationBuilder.RenameIndex(
name: "IX_NodeAcl_Principal",
table: "NodeAcl",
newName: "IX_NodeAcl_Group");
migrationBuilder.Sql(ComputeGenerationDiff("LdapGroup"));
}
/// <summary>
/// <c>dbo.sp_ComputeGenerationDiff</c> re-emitted with the NodeAcl grantee column named by
/// <paramref name="granteeColumn"/>. Kept as one parameterised template so Up and Down cannot
/// drift from each other, and so the diff shows exactly one token changing.
/// </summary>
/// <param name="granteeColumn">The NodeAcl grantee column name to checksum.</param>
/// <returns>The <c>CREATE OR ALTER PROCEDURE</c> statement.</returns>
private static string ComputeGenerationDiff(string granteeColumn) => $@"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
-- @FromGenerationId / @ToGenerationId are ignored: the v3 config tables are single live-state
-- (no GenerationId column). Emit a current-state signature per row so the shape (TableName,
-- LogicalId, Sig) still round-trips through any legacy caller.
SELECT TableName, LogicalId, Sig FROM (
SELECT 'RawFolder' AS TableName, RawFolderId AS LogicalId, CHECKSUM(ClusterId, ParentRawFolderId, Name, SortOrder) AS Sig FROM dbo.RawFolder
UNION ALL
SELECT 'DriverInstance', DriverInstanceId, CHECKSUM(ClusterId, RawFolderId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) FROM dbo.DriverInstance
UNION ALL
SELECT 'Device', DeviceId, CHECKSUM(DriverInstanceId, Name, Enabled, CONVERT(varchar(max), DeviceConfig)) FROM dbo.Device
UNION ALL
SELECT 'TagGroup', TagGroupId, CHECKSUM(DeviceId, ParentTagGroupId, Name, SortOrder) FROM dbo.TagGroup
UNION ALL
SELECT 'Tag', TagId, CHECKSUM(DeviceId, TagGroupId, PollGroupId, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) FROM dbo.Tag
UNION ALL
SELECT 'Equipment', EquipmentId, CHECKSUM(EquipmentUuid, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) FROM dbo.Equipment
UNION ALL
SELECT 'UnsTagReference', UnsTagReferenceId, CHECKSUM(EquipmentId, TagId, DisplayNameOverride, SortOrder) FROM dbo.UnsTagReference
UNION ALL
SELECT 'NodeAcl', NodeAclId, CHECKSUM(ClusterId, {granteeColumn}, ScopeKind, ScopeId, PermissionFlags, Notes) FROM dbo.NodeAcl
) AS inventory;
END
";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddApiKeyTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ApiKey",
columns: table => new
{
ApiKeyRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
ApiKeyId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SecretHash = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Principal = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
ExpiresUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKey", x => x.ApiKeyRowId);
});
migrationBuilder.CreateIndex(
name: "IX_ApiKey_Principal",
table: "ApiKey",
column: "Principal");
migrationBuilder.CreateIndex(
name: "UX_ApiKey_ApiKeyId",
table: "ApiKey",
column: "ApiKeyId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApiKey");
}
}
}
@@ -41,6 +41,64 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("DataProtectionKeys", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey", b =>
{
b.Property<Guid>("ApiKeyRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ApiKeyId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("datetime2");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<DateTime?>("ExpiresUtc")
.HasColumnType("datetime2");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Notes")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Principal")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<string>("SecretHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("ApiKeyRowId");
b.HasIndex("ApiKeyId")
.IsUnique()
.HasDatabaseName("UX_ApiKey_ApiKeyId");
b.HasIndex("Principal")
.HasDatabaseName("IX_ApiKey_Principal");
b.ToTable("ApiKey", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
{
b.Property<string>("NodeId")
@@ -709,11 +767,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("LdapGroup")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NodeAclId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -725,6 +778,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Property<int>("PermissionFlags")
.HasColumnType("int");
b.Property<string>("Principal")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
@@ -745,21 +803,21 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasIndex("ClusterId")
.HasDatabaseName("IX_NodeAcl_Cluster");
b.HasIndex("LdapGroup")
.HasDatabaseName("IX_NodeAcl_Group");
b.HasIndex("NodeAclId")
.IsUnique()
.HasDatabaseName("UX_NodeAcl_LogicalId")
.HasFilter("[NodeAclId] IS NOT NULL");
b.HasIndex("Principal")
.HasDatabaseName("IX_NodeAcl_Principal");
b.HasIndex("ScopeKind", "ScopeId")
.HasDatabaseName("IX_NodeAcl_Scope")
.HasFilter("[ScopeId] IS NOT NULL");
b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId")
b.HasIndex("ClusterId", "Principal", "ScopeKind", "ScopeId")
.IsUnique()
.HasDatabaseName("UX_NodeAcl_GroupScope")
.HasDatabaseName("UX_NodeAcl_PrincipalScope")
.HasFilter("[ScopeId] IS NOT NULL");
b.ToTable("NodeAcl", (string)null);
@@ -40,6 +40,9 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
/// <summary>Gets the DbSet of node ACLs.</summary>
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
/// <summary>OPC UA data-plane API keys (#520). One principal per key; secret stored as a peppered hash.</summary>
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
/// <summary>Gets the DbSet of configuration audit logs.</summary>
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
/// <summary>Gets the DbSet of external ID reservations.</summary>
@@ -84,6 +87,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureUnsTagReference(modelBuilder);
ConfigurePollGroup(modelBuilder);
ConfigureNodeAcl(modelBuilder);
ConfigureApiKey(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
ConfigureExternalIdReservation(modelBuilder);
ConfigureLdapGroupRoleMapping(modelBuilder);
@@ -440,6 +444,29 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
});
}
private static void ConfigureApiKey(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ApiKey>(e =>
{
e.ToTable("ApiKey");
e.HasKey(x => x.ApiKeyRowId);
e.Property(x => x.ApiKeyRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
// IsRequired() deliberately deviates from the NodeAclId precedent, which is a nullable column
// with a filtered unique index. That shape lets two NULL ids coexist; harmless for a config
// row, not something to carry into a credentials table where the id is the lookup key.
e.Property(x => x.ApiKeyId).HasMaxLength(64).IsRequired();
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.SecretHash).HasMaxLength(128);
e.Property(x => x.Principal).HasMaxLength(256);
e.Property(x => x.Notes).HasMaxLength(512);
e.Property(x => x.RowVersion).IsRowVersion();
// The presented key carries ApiKeyId in the clear precisely so verification is this seek.
e.HasIndex(x => x.ApiKeyId).IsUnique().HasDatabaseName("UX_ApiKey_ApiKeyId");
e.HasIndex(x => x.Principal).HasDatabaseName("IX_ApiKey_Principal");
});
}
private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
{
modelBuilder.Entity<NodeAcl>(e =>
@@ -449,7 +476,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NodeAclId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.LdapGroup).HasMaxLength(256);
e.Property(x => x.Principal).HasMaxLength(256);
e.Property(x => x.ScopeKind).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.ScopeId).HasMaxLength(64);
e.Property(x => x.PermissionFlags).HasConversion<int>();
@@ -457,13 +484,13 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_NodeAcl_Cluster");
e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_NodeAcl_Group");
e.HasIndex(x => x.Principal).HasDatabaseName("IX_NodeAcl_Principal");
e.HasIndex(x => new { x.ScopeKind, x.ScopeId })
.HasFilter("[ScopeId] IS NOT NULL")
.HasDatabaseName("IX_NodeAcl_Scope");
e.HasIndex(x => x.NodeAclId).IsUnique().HasDatabaseName("UX_NodeAcl_LogicalId");
e.HasIndex(x => new { x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique()
.HasDatabaseName("UX_NodeAcl_GroupScope");
e.HasIndex(x => new { x.ClusterId, x.Principal, x.ScopeKind, x.ScopeId }).IsUnique()
.HasDatabaseName("UX_NodeAcl_PrincipalScope");
});
}
@@ -0,0 +1,141 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>Create / list / revoke OPC UA data-plane API keys (#520).</summary>
/// <remarks>
/// A service rather than logic in the Razor page, because this repo has no bUnit — anything only a
/// page can reach is untestable, and minting credentials is not where that belongs.
/// </remarks>
public interface IApiKeyAdminService
{
/// <summary>Lists every key, newest first. Never returns a secret — there is none stored.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>All key rows.</returns>
Task<IReadOnlyList<ApiKey>> ListAsync(CancellationToken ct);
/// <summary>
/// Mints a new key. The returned plaintext is the ONLY time it exists — it is not recoverable
/// from what is stored, so a caller that fails to show it has effectively discarded the key.
/// </summary>
/// <param name="name">Human label, e.g. the consuming system.</param>
/// <param name="principal">The principal the key authenticates as.</param>
/// <param name="expiresUtc">Optional expiry.</param>
/// <param name="notes">Optional operator notes.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The presented key, shown once.</returns>
Task<string> CreateAsync(
string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct);
/// <summary>Enables or disables a key.</summary>
/// <param name="apiKeyId">The public key id.</param>
/// <param name="enabled">Whether the key may authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the operation.</returns>
Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct);
/// <summary>Permanently deletes a key row.</summary>
/// <param name="apiKeyId">The public key id.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the operation.</returns>
Task DeleteAsync(string apiKeyId, CancellationToken ct);
}
/// <summary>EF-backed <see cref="IApiKeyAdminService"/>.</summary>
public sealed class ApiKeyAdminService : IApiKeyAdminService
{
/// <summary>Longest accepted principal name, matching the column width.</summary>
public const int MaxPrincipalLength = 256;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _factory;
private readonly string _pepper;
/// <summary>Initializes a new instance of the <see cref="ApiKeyAdminService"/> class.</summary>
/// <param name="factory">The config DB context factory.</param>
/// <param name="pepper">The server-side pepper used to hash key secrets.</param>
public ApiKeyAdminService(IDbContextFactory<OtOpcUaConfigDbContext> factory, string pepper)
{
_factory = factory;
_pepper = pepper;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKey>> ListAsync(CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
return await db.ApiKeys.AsNoTracking().OrderByDescending(k => k.CreatedUtc).ToListAsync(ct)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<string> CreateAsync(
string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct)
{
name = (name ?? string.Empty).Trim();
principal = (principal ?? string.Empty).Trim();
if (name.Length == 0) throw new ArgumentException("A key name is required.", nameof(name));
if (principal.Length == 0)
throw new ArgumentException("A principal is required.", nameof(principal));
if (principal.Length > MaxPrincipalLength)
throw new ArgumentException(
$"Principal must be {MaxPrincipalLength} characters or fewer.", nameof(principal));
// Reserved: a key authenticating as 'anonymous' would silently inherit whatever anonymous was
// granted, and vice versa — two identities meant to be distinct sharing one grant set. The
// authenticator refuses it too; this is the authoring half so it can never be stored at all.
if (string.Equals(principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException(
$"'{OpcUaPrincipals.Anonymous}' is reserved for anonymous sessions and cannot be a key's principal.",
nameof(principal));
if (expiresUtc is { } exp && exp <= DateTime.UtcNow)
throw new ArgumentException("Expiry must be in the future.", nameof(expiresUtc));
// Generate BEFORE opening the context: this throws on an unconfigured pepper, and failing there
// is much clearer than a row that persists against a hash nothing can ever verify.
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(_pepper);
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
db.ApiKeys.Add(new ApiKey
{
ApiKeyId = apiKeyId,
Name = name,
SecretHash = hash,
Principal = principal,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
ExpiresUtc = expiresUtc,
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(),
});
await db.SaveChangesAsync(ct).ConfigureAwait(false);
return presented;
}
/// <inheritdoc />
public async Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
var key = await db.ApiKeys.SingleOrDefaultAsync(k => k.ApiKeyId == apiKeyId, ct)
.ConfigureAwait(false);
if (key is null) return;
key.Enabled = enabled;
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task DeleteAsync(string apiKeyId, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
var key = await db.ApiKeys.SingleOrDefaultAsync(k => k.ApiKeyId == apiKeyId, ct)
.ConfigureAwait(false);
if (key is null) return;
db.ApiKeys.Remove(key);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
}
@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings (#520). The data plane is
/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
/// </summary>
public sealed class ApiKeyOptions
{
/// <summary>The configuration section name.</summary>
public const string SectionName = "Security:ApiKeys";
/// <summary>
/// The static username every key-bearing client presents. The username carries no information —
/// the key identifies the principal — but OPC UA's UserName token requires one, so it is fixed
/// and checked rather than ignored, which keeps a typo an explicit denial instead of a silent
/// "any username works".
/// </summary>
public string Username { get; set; } = "apikey";
/// <summary>
/// Server-side pepper for the stored key hashes. Supply via <c>Security__ApiKeys__Pepper</c> or a
/// <c>${secret:}</c> reference — <b>never commit it</b>. Rotating it invalidates every existing key.
/// </summary>
/// <remarks>
/// Deliberately has no default. An empty pepper still hashes "successfully" and would produce a
/// working-looking deployment with none of the protection it believes it has, so
/// <c>ApiKeySecret.ComputeHash</c> throws on one and this stays empty to make that fire early.
/// </remarks>
public string Pepper { get; set; } = string.Empty;
}
@@ -0,0 +1,129 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Pure helpers for the OPC UA data-plane API key format <c>otua_&lt;keyId&gt;_&lt;secret&gt;</c>
/// (#520): generation, parsing, and constant-time verification. No I/O and no DI, so the
/// security-critical half is unit-testable on its own.
/// </summary>
/// <remarks>
/// <para>
/// The format mirrors the family convention — <c>histgw_&lt;id&gt;_&lt;secret&gt;</c> in
/// HistorianGateway, <c>apikey create-key</c> in mxaccessgw. The <c>keyId</c> travels in the clear
/// so a lookup is an index seek; only the secret half is hashed.
/// </para>
/// <para>
/// <b>Why a plain HMAC and not PBKDF2/Argon2.</b> Slow password hashing exists to make guessing a
/// low-entropy human secret expensive. A key secret here is <see cref="SecretBytes"/> bytes of
/// CSPRNG output, so guessing is already infeasible and an iterated KDF would only add latency to
/// a call that runs inline on OPC UA session activation — which the SDK serializes server-wide
/// behind its SessionManager event lock. The pepper is what defends a leaked database: an
/// attacker with the table but not the pepper cannot verify a guess offline.
/// </para>
/// <para>
/// ⚠️ <b>An unset pepper fails closed.</b> <see cref="ComputeHash"/> throws rather than hashing
/// under an empty key, so a deployment that forgets to configure one cannot silently accept keys
/// whose hashes were all computed under the same empty pepper.
/// </para>
/// </remarks>
public static class ApiKeySecret
{
/// <summary>The literal prefix identifying an OtOpcUa data-plane key.</summary>
public const string Prefix = "otua";
/// <summary>Bytes of CSPRNG output behind the public key id.</summary>
public const int KeyIdBytes = 8;
/// <summary>Bytes of CSPRNG output behind the secret segment.</summary>
public const int SecretBytes = 32;
/// <summary>
/// Generates a new key. The returned <c>Presented</c> value is the only time the secret exists
/// outside the caller — it is not recoverable from <c>SecretHash</c>.
/// </summary>
/// <param name="pepper">Server-side pepper; must be non-empty.</param>
/// <returns>The presented key string, its public key id, and the hash to persist.</returns>
public static (string Presented, string ApiKeyId, string SecretHash) Generate(string pepper)
{
// The key id is HEX, not url-safe base64: that alphabet maps '/' to '_', so a base64 key id could
// contain the very character TryParse splits on and would be truncated at a random position. The
// secret is the whole remainder after the second '_', so underscores there are harmless.
var keyId = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(KeyIdBytes));
var secret = ToUrlSafeBase64(RandomNumberGenerator.GetBytes(SecretBytes));
return ($"{Prefix}_{keyId}_{secret}", keyId, ComputeHash(secret, pepper));
}
/// <summary>
/// Splits a presented key into its public id and secret halves.
/// </summary>
/// <remarks>
/// Splits on the FIRST two underscores only, so a secret that itself contains an underscore round
/// trips. Returns false rather than throwing — a malformed credential is an ordinary
/// authentication failure, not an exceptional condition, and it arrives from the network.
/// </remarks>
/// <param name="presented">The presented key string.</param>
/// <param name="apiKeyId">The parsed public key id.</param>
/// <param name="secret">The parsed secret segment.</param>
/// <returns><see langword="true"/> when the value is well-formed.</returns>
public static bool TryParse(string? presented, out string apiKeyId, out string secret)
{
apiKeyId = string.Empty;
secret = string.Empty;
if (string.IsNullOrEmpty(presented)) return false;
var first = presented.IndexOf('_');
if (first <= 0) return false;
if (!presented.AsSpan(0, first).SequenceEqual(Prefix)) return false;
var second = presented.IndexOf('_', first + 1);
if (second < 0) return false;
apiKeyId = presented[(first + 1)..second];
secret = presented[(second + 1)..];
return apiKeyId.Length > 0 && secret.Length > 0;
}
/// <summary>Computes the peppered HMAC-SHA256 of a secret segment, base64-encoded.</summary>
/// <param name="secret">The secret segment.</param>
/// <param name="pepper">Server-side pepper; must be non-empty.</param>
/// <returns>The base64 hash.</returns>
/// <exception cref="ArgumentException">The pepper is null, empty, or whitespace.</exception>
public static string ComputeHash(string secret, string pepper)
{
if (string.IsNullOrWhiteSpace(pepper))
{
// Fail closed. Hashing under an empty pepper would "work" and produce a stable value, so a
// misconfigured deployment would authenticate normally while offering no protection at all.
throw new ArgumentException(
"An API key pepper must be configured (Security:ApiKeys:Pepper). Refusing to hash under an empty pepper.",
nameof(pepper));
}
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(pepper));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(secret)));
}
/// <summary>
/// Constant-time comparison of a presented secret against a stored hash.
/// </summary>
/// <param name="secret">The presented secret segment.</param>
/// <param name="pepper">Server-side pepper.</param>
/// <param name="storedHash">The persisted base64 hash.</param>
/// <returns><see langword="true"/> when they match.</returns>
public static bool Verify(string secret, string pepper, string? storedHash)
{
if (string.IsNullOrEmpty(storedHash)) return false;
byte[] expected;
try { expected = Convert.FromBase64String(storedHash); }
catch (FormatException) { return false; }
var actual = Convert.FromBase64String(ComputeHash(secret, pepper));
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
private static string ToUrlSafeBase64(byte[] bytes) =>
Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
@@ -43,10 +43,10 @@ public enum AuthorizationVerdict
}
/// <summary>One grant that contributed to an Allow verdict — for audit / UI diagnostics.</summary>
/// <param name="LdapGroup">LDAP group the matched grant belongs to.</param>
/// <param name="Principal">Principal the matched grant belongs to.</param>
/// <param name="Scope">Where in the hierarchy the grant was anchored.</param>
/// <param name="PermissionFlags">The bitmask the grant contributed.</param>
public sealed record MatchedGrant(
string LdapGroup,
string Principal,
NodeAclScopeKind Scope,
NodePermissions PermissionFlags);
@@ -0,0 +1,235 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// The data-plane authorization inputs a node is currently serving: the API keys that may
/// authenticate, the ACL grants naming their principals, the cluster the node belongs to, and the
/// generation those were resolved at (#520, stage 4).
/// </summary>
/// <remarks>
/// <para>
/// Immutable. A new deployment produces a whole new snapshot which is swapped in atomically, so
/// a request that started against the previous generation finishes against a coherent set
/// rather than a half-updated one.
/// </para>
/// <para>
/// The lookups are precomputed because the consumer is OPC UA session activation, which the SDK
/// raises <b>synchronously under a <c>SessionManager</c>-wide lock</b> — every session in the
/// server serializes behind it. A dictionary probe is the whole cost.
/// </para>
/// </remarks>
public sealed class DataPlaneAuthoritySnapshot
{
/// <summary>The state of a node that has not yet applied an artifact. Authenticates nothing.</summary>
public static readonly DataPlaneAuthoritySnapshot Empty = Create(null, string.Empty, 0, [], []);
private DataPlaneAuthoritySnapshot(
string? clusterId,
string revisionHash,
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<string, ApiKey> keysById,
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
{
ClusterId = clusterId;
RevisionHash = revisionHash;
GenerationId = generationId;
Keys = keys;
Grants = grants;
_keysById = keysById;
_grantsByPrincipal = grantsByPrincipal;
}
private readonly IReadOnlyDictionary<string, ApiKey> _keysById;
private readonly IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> _grantsByPrincipal;
/// <summary>
/// The cluster this node belongs to per the artifact's <c>ClusterNode</c> row, or null on a
/// single-cluster / legacy artifact where no scoping applies.
/// </summary>
public string? ClusterId { get; }
/// <summary>SHA-256 of the artifact these inputs came from; empty before the first apply.</summary>
public string RevisionHash { get; }
/// <summary>
/// Monotonic, node-local generation stamp. Zero means "nothing applied yet"; it increases by one
/// each time an artifact with a <em>different</em> <see cref="RevisionHash"/> is applied.
/// </summary>
public long GenerationId { get; }
/// <summary>Every key in the artifact, revoked and expired ones included — filtering is the lookup's job.</summary>
public IReadOnlyList<ApiKey> Keys { get; }
/// <summary>Every ACL grant scoped to this node's cluster.</summary>
public IReadOnlyList<NodeAcl> Grants { get; }
/// <summary>True once a real artifact has been applied.</summary>
public bool HasArtifact => GenerationId > 0;
/// <summary>
/// Finds a key that is enabled and unexpired at <paramref name="utcNow"/>. A revoked or expired
/// key returns null rather than a "found but unusable" result, mirroring
/// <c>ConfigDbApiKeyStore</c>, which filters both in SQL for the same reason: a key that must
/// not authenticate should never reach a caller that might treat a non-null return as a match.
/// </summary>
/// <param name="apiKeyId">The public key id parsed from the presented credential.</param>
/// <param name="utcNow">Current UTC time, for the expiry check.</param>
/// <returns>The usable key, or null.</returns>
public ApiKey? FindUsableKey(string apiKeyId, DateTime utcNow)
{
if (string.IsNullOrEmpty(apiKeyId)) return null;
if (!_keysById.TryGetValue(apiKeyId, out var key)) return null;
if (!key.Enabled) return null;
if (key.ExpiresUtc is { } expires && expires <= utcNow) return null;
return key;
}
/// <summary>Returns the grants naming a principal; empty when none.</summary>
/// <param name="principal">The principal to look up.</param>
/// <returns>The matching grants.</returns>
public IReadOnlyList<NodeAcl> GrantsFor(string principal) =>
!string.IsNullOrEmpty(principal) && _grantsByPrincipal.TryGetValue(principal, out var g)
? g
: [];
/// <summary>Builds a snapshot, precomputing the key and grant lookups.</summary>
/// <param name="clusterId">The node's cluster, or null when the artifact is single-cluster.</param>
/// <param name="revisionHash">SHA-256 of the source artifact.</param>
/// <param name="generationId">The node-local generation stamp.</param>
/// <param name="keys">The API keys carried by the artifact.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <returns>The built snapshot.</returns>
public static DataPlaneAuthoritySnapshot Create(
string? clusterId,
string revisionHash,
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants)
{
ArgumentNullException.ThrowIfNull(revisionHash);
ArgumentNullException.ThrowIfNull(keys);
ArgumentNullException.ThrowIfNull(grants);
// OrdinalIgnoreCase throughout: key ids are minted as lowercase hex, but a client hand-copying a
// key out of the AdminUI can upper-case it, and the principal comparison must agree with the
// evaluator's (TriePermissionEvaluator matches principals OrdinalIgnoreCase).
var byId = new Dictionary<string, ApiKey>(keys.Count, StringComparer.OrdinalIgnoreCase);
foreach (var key in keys)
{
if (string.IsNullOrWhiteSpace(key.ApiKeyId)) continue;
// A duplicate id cannot come from the database (UX_ApiKey_ApiKeyId is unique), so this only
// fires on a corrupt artifact. Keep the FIRST and drop the rest rather than throwing: the
// alternative is a node that refuses to authenticate anything because one row is malformed.
byId.TryAdd(key.ApiKeyId, key);
}
var byPrincipal = new Dictionary<string, List<NodeAcl>>(StringComparer.OrdinalIgnoreCase);
foreach (var grant in grants)
{
if (string.IsNullOrWhiteSpace(grant.Principal)) continue;
if (!byPrincipal.TryGetValue(grant.Principal, out var list))
byPrincipal[grant.Principal] = list = [];
list.Add(grant);
}
var frozen = byPrincipal.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyList<NodeAcl>)kv.Value,
StringComparer.OrdinalIgnoreCase);
return new DataPlaneAuthoritySnapshot(clusterId, revisionHash, generationId, keys, grants, byId, frozen);
}
}
/// <summary>Read side of the node's current data-plane authorization inputs.</summary>
public interface IDataPlaneAuthoritySource
{
/// <summary>The snapshot in force right now. Never null; <see cref="DataPlaneAuthoritySnapshot.Empty"/> before the first apply.</summary>
DataPlaneAuthoritySnapshot Current { get; }
}
/// <summary>
/// The source bound when nothing has been wired: permanently empty, so it authenticates no key and
/// grants no permission. The safe default for a node that is not serving a data plane.
/// </summary>
public sealed class EmptyDataPlaneAuthoritySource : IDataPlaneAuthoritySource
{
/// <summary>The shared instance.</summary>
public static readonly EmptyDataPlaneAuthoritySource Instance = new();
private EmptyDataPlaneAuthoritySource() { }
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty;
}
/// <summary>
/// Write side, fed once per deployment from the node's artifact-apply path.
/// </summary>
public interface IDataPlaneAuthoritySink
{
/// <summary>
/// Installs the authorization inputs carried by a newly applied artifact. Idempotent: re-applying
/// the same <paramref name="revisionHash"/> leaves the generation stamp untouched.
/// </summary>
/// <param name="clusterId">The node's cluster per the artifact, or null when single-cluster.</param>
/// <param name="revisionHash">SHA-256 of the artifact bytes.</param>
/// <param name="keys">The API keys the artifact carries.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <returns>The installed snapshot.</returns>
DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants);
}
/// <summary>
/// Process-singleton holder of <see cref="DataPlaneAuthoritySnapshot"/>. Registered on every
/// driver-role node; the artifact-apply path writes it and OPC UA session activation reads it.
/// </summary>
public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPlaneAuthoritySink
{
private readonly Lock _gate = new();
private DataPlaneAuthoritySnapshot _current = DataPlaneAuthoritySnapshot.Empty;
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current);
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants)
{
ArgumentNullException.ThrowIfNull(revisionHash);
lock (_gate)
{
var previous = _current;
// The generation is a node-local counter driven BY the revision hash, not derived FROM it.
// Deriving a long from hash bytes was the obvious reading of "stamp the generation from the
// RevisionHash", and it is wrong: PermissionTrieCache treats the highest GenerationId as the
// current one (ClusterEntry.WithAdditional) and Prune keeps the highest. Hash fragments are
// unordered, so roughly half of all deploys would install a LOWER generation and the cache
// would keep serving the previous deployment's ACLs — a silent stale-authorization bug with
// no error anywhere. A counter is monotonic by construction.
//
// It resets to zero on restart, which is safe: sessions do not survive a restart and the trie
// cache is in-process, so there is no older generation left to be out-ordered by.
var generation = string.Equals(previous.RevisionHash, revisionHash, StringComparison.Ordinal)
&& previous.HasArtifact
? previous.GenerationId
: previous.GenerationId + 1;
var next = DataPlaneAuthoritySnapshot.Create(clusterId, revisionHash, generation, keys, grants);
Volatile.Write(ref _current, next);
return next;
}
}
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>Reserved principal names for the OPC UA data plane (#520).</summary>
/// <remarks>
/// Lives in Core rather than beside the authenticator because BOTH ends need it: the OPC UA server
/// resolves an anonymous session to it, and the AdminUI authoring path must refuse to mint a key
/// that claims it.
/// </remarks>
public static class OpcUaPrincipals
{
/// <summary>
/// The principal an anonymous session authenticates as. <c>NodeAcl</c> rows naming it grant
/// anonymous clients permissions exactly as they would a key's principal.
/// </summary>
/// <remarks>
/// Reserved: an API key must not use this principal, or a credentialled client would silently
/// inherit whatever anonymous was granted (and vice versa). Refused at authoring AND at
/// authentication — belt and braces, because the two paths can be reached independently.
/// </remarks>
public const string Anonymous = "anonymous";
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster
/// root down through namespace → UNS levels → tag, OR-ing the
/// <see cref="TrieGrant.PermissionFlags"/> granted at each visited level for each of the session's
/// LDAP groups. The accumulated bitmask is compared to the permission required by the
/// principals. The accumulated bitmask is compared to the permission required by the
/// requested <see cref="Abstractions.OpcUaOperation"/>.
/// </summary>
/// <remarks>
@@ -28,18 +28,18 @@ public sealed class PermissionTrie
/// <summary>
/// Walk the trie collecting grants that apply to <paramref name="scope"/> for any of the
/// session's <paramref name="ldapGroups"/>. Returns the matched-grant list; the caller
/// session's <paramref name="principals"/>. Returns the matched-grant list; the caller
/// OR-s the flag bits to decide whether the requested permission is carried.
/// </summary>
/// <param name="scope">The node scope to match permissions for.</param>
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
/// <param name="principals">The principal names carried by the session (API-key principals, or the reserved "anonymous").</param>
/// <returns>The matched grants accumulated across every trie level visited for the scope.</returns>
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> principals)
{
ArgumentNullException.ThrowIfNull(scope);
ArgumentNullException.ThrowIfNull(ldapGroups);
ArgumentNullException.ThrowIfNull(principals);
var groups = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase);
var groups = principals.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (groups.Count == 0) return [];
var matches = new List<MatchedGrant>();
@@ -83,8 +83,8 @@ public sealed class PermissionTrie
{
foreach (var grant in node.Grants)
{
if (groups.Contains(grant.LdapGroup))
matches.Add(new MatchedGrant(grant.LdapGroup, level, grant.PermissionFlags));
if (groups.Contains(grant.Principal))
matches.Add(new MatchedGrant(grant.Principal, level, grant.PermissionFlags));
}
}
}
@@ -104,4 +104,4 @@ public sealed class PermissionTrieNode
}
/// <summary>Projection of a <see cref="Configuration.Entities.NodeAcl"/> row into the trie.</summary>
public sealed record TrieGrant(string LdapGroup, NodePermissions PermissionFlags);
public sealed record TrieGrant(string Principal, NodePermissions PermissionFlags);
@@ -57,7 +57,7 @@ public static class PermissionTrieBuilder
foreach (var row in rows)
{
if (!string.Equals(row.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
var grant = new TrieGrant(row.LdapGroup, row.PermissionFlags);
var grant = new TrieGrant(row.Principal, row.PermissionFlags);
var node = row.ScopeKind switch
{
@@ -48,7 +48,7 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
if (trie is null) return AuthorizationDecision.NotGranted();
}
var matches = trie.CollectMatches(scope, session.LdapGroups);
var matches = trie.CollectMatches(scope, session.Principals);
if (matches.Count == 0) return AuthorizationDecision.NotGranted();
var required = MapOperationToPermission(operation);
@@ -2,14 +2,28 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Per-session authorization state cached on the OPC UA session object + keyed on the
/// session id. Captures the LDAP group memberships resolved at sign-in, the generation
/// the membership was resolved against, and the bounded freshness window.
/// session id. Captures the principals resolved at session activation, the generation
/// they were resolved against, and the bounded freshness window.
/// </summary>
/// <remarks>
/// ⚠️ <b>The refresh machinery below is LDAP-shaped and is largely vestigial under the
/// API-key model (#520).</b> It exists because an LDAP group membership can change
/// out-of-band in the directory, so a long-lived session had to re-resolve periodically.
/// An API-key principal set cannot change out-of-band: keys and ACL rows both ride the
/// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only
/// thing that can change a session's grants is a new deploy — which already bumps
/// <see cref="AuthGenerationId"/>. The generation stamp, not the freshness timer, is the
/// real invalidation signal. Stage 4 built that signal — <see cref="DataPlaneAuthorityState"/>
/// advances a monotonic generation on every artifact carrying a new <c>RevisionHash</c> — but
/// deliberately did NOT construct this type per session: nothing reads a
/// <see cref="UserAuthorizationState"/> until the stage 5 enforcement seams exist, and a
/// per-session object that nothing consults is dormant wiring. So the question of whether
/// <see cref="MembershipFreshnessInterval"/> / <see cref="NeedsRefresh"/> earn their keep moves
/// to stage 5, where sessions are actually stamped.
///
/// The membership is bounded by <see cref="MembershipFreshnessInterval"/>
/// (default 5 min). After that, the next hot-path authz call re-resolves LDAP group
/// memberships; failure to re-resolve (LDAP unreachable) flips the session to fail-closed
/// until a refresh succeeds.
/// (default 5 min). After that, the next hot-path authz call re-resolves the principal
/// set; failure to re-resolve flips the session to fail-closed until a refresh succeeds.
///
/// <see cref="AuthCacheMaxStaleness"/> (default 15 min) is separate from
/// Phase 6.1's availability-oriented 24h cache — beyond this window the evaluator returns
@@ -30,12 +44,14 @@ public sealed record UserAuthorizationState
public required string ClusterId { get; init; }
/// <summary>
/// LDAP groups the user is a member of as resolved at sign-in / last membership refresh.
/// Principals the session carries, as resolved at session activation: the principal names
/// attached to the presented API key, or the single reserved <c>anonymous</c> principal for
/// an anonymous session when anonymous access is enabled.
/// Case comparison is handled downstream by the evaluator (OrdinalIgnoreCase).
/// </summary>
public required IReadOnlyList<string> LdapGroups { get; init; }
public required IReadOnlyList<string> Principals { get; init; }
/// <summary>Timestamp when <see cref="LdapGroups"/> was last resolved from the directory.</summary>
/// <summary>Timestamp when <see cref="Principals"/> was last resolved.</summary>
public required DateTime MembershipResolvedUtc { get; init; }
/// <summary>
@@ -20,6 +20,7 @@
<NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" />
<NavRailItem Href="/apikeys" Text="API keys" />
<NavRailItem Href="/admin/secrets" Text="Secrets" />
</NavRailSection>
<NavRailSection Title="Scripting" Key="scripting">
@@ -0,0 +1,209 @@
@page "/apikeys"
@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)]
@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
@inject IApiKeyAdminService Keys
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">OPC UA API keys</h4>
</div>
<div class="alert alert-info" role="alert">
The OPC UA data plane is machine-to-machine. A client connects with an OPC UA
<strong>UserName</strong> token whose username is the literal <span class="mono">apikey</span> and
whose password is the key below. The key resolves to a <strong>principal</strong>; ACL rows naming
that principal are what grant it permissions.
<br />
<strong>Revocation is not uniform across the fleet.</strong> A node with a config database (a fused
admin+driver node such as this one) applies it <em>immediately</em>, because it reads keys per
request. A <strong>driver-only</strong> node has no config database, so its keys travel inside the
deployment artifact and a revocation reaches it only on the <strong>next deployment</strong>.
Assume the slower behaviour when revoking a compromised key.
</div>
@if (_created is not null)
{
<div class="alert alert-success" role="alert">
<strong>Copy this key now — it is not stored and cannot be shown again.</strong>
<pre class="mono mt-2 mb-0" style="white-space:pre-wrap;word-break:break-all">@_created</pre>
<button class="btn btn-sm btn-secondary mt-2" @onclick="() => _created = null">Dismiss</button>
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="alert alert-danger" role="alert">@_error</div>
}
<section class="panel mb-3">
<div class="panel-head">Create a key</div>
<div style="padding:1rem">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label" for="k-name">Name</label>
<input id="k-name" class="form-control form-control-sm" @bind="_newName"
placeholder="ScadaBridge DCL" />
</div>
<div class="col-md-3">
<label class="form-label" for="k-principal">Principal</label>
<input id="k-principal" class="form-control form-control-sm mono" @bind="_newPrincipal"
placeholder="scadabridge-dcl" />
</div>
<div class="col-md-3">
<label class="form-label" for="k-expires">Expires (optional, UTC)</label>
<input id="k-expires" type="date" class="form-control form-control-sm" @bind="_newExpires" />
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-sm btn-primary" @onclick="CreateAsync" disabled="@_busy">Create key</button>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">Keys</div>
@if (_keys is null)
{
<div style="padding:1rem" class="text-muted">Loading…</div>
}
else if (_keys.Count == 0)
{
<div style="padding:1rem" class="text-muted">
No keys yet. Until one exists, only anonymous sessions can connect.
</div>
}
else
{
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Name</th>
<th>Principal</th>
<th>Key id</th>
<th>Status</th>
<th>Created (UTC)</th>
<th>Expires (UTC)</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var k in _keys)
{
<tr>
<td>@k.Name</td>
<td><span class="mono">@k.Principal</span></td>
<td><span class="mono">@k.ApiKeyId</span></td>
<td>
@if (!k.Enabled)
{
<span class="badge bg-secondary">revoked</span>
}
else if (k.ExpiresUtc is { } e && e <= DateTime.UtcNow)
{
<span class="badge bg-warning text-dark">expired</span>
}
else
{
<span class="badge bg-success">active</span>
}
</td>
<td>@k.CreatedUtc.ToString("yyyy-MM-dd HH:mm")</td>
<td>@(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—")</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SetEnabledAsync(k.ApiKeyId, !k.Enabled)" disabled="@_busy">
@(k.Enabled ? "Revoke" : "Re-enable")
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteAsync(k.ApiKeyId)" disabled="@_busy">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</section>
@code {
private IReadOnlyList<ApiKey>? _keys;
private string _newName = "";
private string _newPrincipal = "";
private DateTime? _newExpires;
private string? _created;
private string? _error;
private bool _busy;
protected override async Task OnInitializedAsync() => await ReloadAsync();
private async Task ReloadAsync()
{
try
{
_keys = await Keys.ListAsync(default);
}
catch (Exception ex)
{
_error = ex.Message;
_keys = [];
}
}
private async Task CreateAsync()
{
_busy = true;
_error = null;
_created = null;
try
{
// Shown once, held only in this component's state. There is no way to recover it afterwards
// — the server stores a hash — so it is deliberately not written anywhere else.
_created = await Keys.CreateAsync(
_newName,
_newPrincipal,
// A date-only input means midnight; treat it as UTC rather than local so an operator
// near a timezone boundary does not get a key that expired before it was issued.
_newExpires is { } d ? DateTime.SpecifyKind(d, DateTimeKind.Utc) : null,
notes: null,
default);
_newName = "";
_newPrincipal = "";
_newExpires = null;
await ReloadAsync();
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_busy = false;
}
}
private async Task SetEnabledAsync(string apiKeyId, bool enabled)
{
_busy = true;
_error = null;
try
{
await Keys.SetEnabledAsync(apiKeyId, enabled, default);
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync(string apiKeyId)
{
_busy = true;
_error = null;
try
{
await Keys.DeleteAsync(apiKeyId, default);
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
}
@@ -52,8 +52,8 @@ else
<InputText id="aid" @bind-Value="_form.NodeAclId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="grp">LDAP group</label>
<InputText id="grp" @bind-Value="_form.LdapGroup" class="form-control form-control-sm mono"
<label class="form-label" for="grp">Principal</label>
<InputText id="grp" @bind-Value="_form.Principal" class="form-control form-control-sm mono"
placeholder="cn=Operators,ou=FleetAdmin,dc=zb,dc=local" />
</div>
</div>
@@ -150,7 +150,7 @@ else
_form = new FormModel
{
NodeAclId = _existing.NodeAclId,
LdapGroup = _existing.LdapGroup,
Principal = _existing.Principal,
ScopeKind = _existing.ScopeKind,
ScopeId = _existing.ScopeId,
PermissionFlags = _existing.PermissionFlags,
@@ -176,7 +176,7 @@ else
{
NodeAclId = _form.NodeAclId,
ClusterId = ClusterId,
LdapGroup = _form.LdapGroup,
Principal = _form.Principal,
ScopeKind = _form.ScopeKind,
ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId,
PermissionFlags = _form.PermissionFlags,
@@ -188,7 +188,7 @@ else
var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.LdapGroup = _form.LdapGroup;
entity.Principal = _form.Principal;
entity.ScopeKind = _form.ScopeKind;
entity.ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId;
entity.PermissionFlags = _form.PermissionFlags;
@@ -224,7 +224,7 @@ else
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string NodeAclId { get; set; } = "";
[Required] public string LdapGroup { get; set; } = "";
[Required] public string Principal { get; set; } = "";
public NodeAclScopeKind ScopeKind { get; set; } = NodeAclScopeKind.Cluster;
public string? ScopeId { get; set; }
public NodePermissions PermissionFlags { get; set; } = NodePermissions.None;
@@ -33,7 +33,7 @@ else
</div>
<section class="panel notice rise" style="animation-delay:.02s">
ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope
ACL rows grant principals specific <span class="mono">NodePermissions</span> on a scope
(a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of
fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained per-node scope.
</section>
@@ -51,7 +51,7 @@ else
<thead>
<tr>
<th>NodeAclId</th>
<th>LDAP group</th>
<th>Principal</th>
<th>Scope</th>
<th>Scope target</th>
<th>Permissions</th>
@@ -64,7 +64,7 @@ else
{
<tr>
<td><span class="mono small">@a.NodeAclId</span></td>
<td><span class="mono">@a.LdapGroup</span></td>
<td><span class="mono">@a.Principal</span></td>
<td>@a.ScopeKind</td>
<td><span class="mono small">@(a.ScopeId ?? "—")</span></td>
<td>
@@ -23,6 +23,9 @@
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<!-- #520: API-key minting + the reserved-principal rule live in Core.Authorization, because
both ends need them — the OPC UA server resolves principals, the AdminUI authors keys. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
</ItemGroup>
@@ -49,6 +49,13 @@ public static class ConfigComposer
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
// #520 stage 4: the data-plane credentials travel with the config, because a driver-only node
// has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every
// UserName token. Only the peppered HMAC of each secret ships — never the secret — and the
// pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob
// still cannot be turned into a working credential. This is also why revoking a key reaches a
// driver-only node only on the next deployment; see the ApiKey entity's remarks.
ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
@@ -48,17 +48,12 @@ public sealed class LdapOptionsValidator : OptionsValidatorBase<LdapOptions>
!(options.Transport == LdapTransport.None && !options.AllowInsecure),
"LDAP transport is None (plaintext) but AllowInsecure is false — set Transport to Ldaps/StartTls or set AllowInsecure for dev.");
// S2 resilience knobs — a non-positive timeout / concurrency bound would silently disable the
// wall-clock/concurrency protection the OPC UA data-plane authenticator relies on, so fail fast.
// A non-positive connect/search timeout would leave the shared library unbounded against a hung
// directory, so fail fast. The other four S2 knobs validated here (AuthTimeoutMs,
// MaxConcurrentAuthentications, OutageFailureThreshold, OutageCooldownSeconds) went with
// LdapOpcUaUserAuthenticator in #520 — they guarded an LDAP bind running inline on OPC UA
// session activation, and no remaining LDAP consumer runs on that path.
builder.RequireThat(options.ConnectionTimeoutMs > 0,
"Ldap:ConnectionTimeoutMs must be > 0.");
builder.RequireThat(options.AuthTimeoutMs > 0,
"Ldap:AuthTimeoutMs must be > 0.");
builder.RequireThat(options.MaxConcurrentAuthentications > 0,
"Ldap:MaxConcurrentAuthentications must be > 0.");
// An open circuit with no cooldown would re-probe LDAP on every call — pointless. Only enforce
// a positive cooldown when the circuit is actually enabled (OutageFailureThreshold > 0; 0 disables).
builder.RequireThat(options.OutageFailureThreshold <= 0 || options.OutageCooldownSeconds > 0,
"Ldap:OutageCooldownSeconds must be > 0 when Ldap:OutageFailureThreshold > 0 (0 disables the circuit).");
}
}
@@ -0,0 +1,67 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// Derives the interim server-wide role set for an authenticated principal from its
/// <see cref="NodeAcl"/> grants (#520, stage 2).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists at all.</b> The two live authorization gates —
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> on Value writes and
/// <see cref="OpcUaDataPlaneRoles.AlarmAck"/> on the Part 9 alarm methods — check a server-wide
/// role set on the session identity. Replacing LDAP with API keys therefore has to keep
/// producing that role set, or the cutover would lock every client out of writes and alarm acks
/// long before per-node ACL evaluation lands in stage 5.
/// </para>
/// <para>
/// ⚠️ <b>Only <see cref="NodeAclScopeKind.Cluster"/> grants confer a role. This is deliberate,
/// and it under-grants.</b> The obvious implementation — union the permission flags across all of
/// a principal's grants — would let a grant authored narrowly ("WriteOperate on Equipment X")
/// confer the server-wide WriteOperate role, because nothing here can enforce scope yet. That
/// fails in the dangerous direction: the operator's narrow intent silently becomes broad
/// authority. Restricting to cluster-scoped grants means a narrow grant does nothing until
/// stage 5 can honour its scope, which is no worse than today (where every ACL row does
/// nothing) and never exceeds what was authored.
/// </para>
/// <para>
/// Scope-narrower-than-cluster grants are not an error and are not logged as one — they are
/// simply inert until enforcement lands. The AdminUI banner is where an operator is told that.
/// </para>
/// </remarks>
public static class ApiKeyInterimRoleMapper
{
/// <summary>
/// Maps a principal's cluster-scoped grants to the server-wide data-plane roles.
/// </summary>
/// <param name="grants">Candidate ACL rows. Rows for other principals are ignored.</param>
/// <param name="principal">The authenticated principal.</param>
/// <returns>The distinct role names to attach to the session identity; never null.</returns>
public static IReadOnlyList<string> Map(IEnumerable<NodeAcl> grants, string principal)
{
ArgumentNullException.ThrowIfNull(grants);
if (string.IsNullOrWhiteSpace(principal)) return Array.Empty<string>();
var effective = NodePermissions.None;
foreach (var grant in grants)
{
if (grant.ScopeKind != NodeAclScopeKind.Cluster) continue;
if (!string.Equals(grant.Principal, principal, StringComparison.OrdinalIgnoreCase)) continue;
effective |= grant.PermissionFlags;
}
var roles = new List<string>(2);
if (effective.HasFlag(NodePermissions.WriteOperate)) roles.Add(OpcUaDataPlaneRoles.WriteOperate);
// AlarmAcknowledge is the flag an operator authors; AlarmAck is the role the Part 9 method gates
// check. The gate covers acknowledge/confirm/shelve as one role, so Confirm/Shelve do not map
// separately — mapping them would imply a granularity the gate does not have.
if (effective.HasFlag(NodePermissions.AlarmAcknowledge)) roles.Add(OpcUaDataPlaneRoles.AlarmAck);
return roles;
}
}
@@ -0,0 +1,176 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
// Explicit: an unqualified Configuration.* inside this namespace binds to Host.Configuration, not to
// the ZB.MOM.WW.OtOpcUa.Configuration assembly.
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// Production <see cref="IOpcUaUserAuthenticator"/> for the machine-to-machine data plane (#520):
/// the client presents the static username <c>apikey</c> with an
/// <c>otua_&lt;keyId&gt;_&lt;secret&gt;</c> credential as the password. Replaces the LDAP adapter.
/// </summary>
/// <remarks>
/// <para>
/// <b>This runs inline on session activation.</b> The SDK raises <c>ImpersonateUser</c>
/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not
/// stall one request — it serializes every session activation server-wide. That is why
/// verification is a single indexed lookup plus one HMAC, and why there is no directory call,
/// no retry, and no network I/O on this path at all. It is the main practical advantage of keys
/// over the LDAP bind this replaces.
/// </para>
/// <para>
/// <b>Fail closed, and say little.</b> Every rejection returns the same opaque message. The
/// reason is logged server-side but never returned, so a caller cannot distinguish "no such key"
/// from "wrong secret" from "key disabled" and use the server as an oracle.
/// </para>
/// </remarks>
public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
/// <summary>
/// Returned for every failure mode, deliberately uninformative. Public so a test can assert that
/// the failure modes really are indistinguishable, rather than restating the literal and passing
/// even if the implementation started differentiating them.
/// </summary>
public const string DenyMessage = "Invalid API key.";
private readonly IApiKeyStore _store;
private readonly IOptions<ApiKeyOptions> _options;
private readonly ILogger<ApiKeyOpcUaUserAuthenticator> _log;
private readonly TimeProvider _time;
/// <summary>Initializes a new instance of the <see cref="ApiKeyOpcUaUserAuthenticator"/> class.</summary>
/// <param name="store">The API key source.</param>
/// <param name="options">The <c>Security:ApiKeys</c> options.</param>
/// <param name="log">The logger.</param>
/// <param name="time">Time provider, injected so expiry is testable.</param>
public ApiKeyOpcUaUserAuthenticator(
IApiKeyStore store,
IOptions<ApiKeyOptions> options,
ILogger<ApiKeyOpcUaUserAuthenticator> log,
TimeProvider? time = null)
{
_store = store;
_options = options;
_log = log;
_time = time ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
string username, string password, CancellationToken ct)
{
var opts = _options.Value;
// The username carries no information, but check it rather than ignore it: a client sending the
// wrong username gets an explicit denial it can diagnose, instead of "any username works".
if (!string.Equals(username, opts.Username, StringComparison.Ordinal))
{
_log.LogInformation(
"ApiKeyAuth: denied — username {Presented} is not the configured {Expected}",
username, opts.Username);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (!ApiKeySecret.TryParse(password, out var apiKeyId, out var secret))
{
_log.LogInformation("ApiKeyAuth: denied — credential is not a well-formed otua_ key");
return OpcUaUserAuthResult.Deny(DenyMessage);
}
var now = _time.GetUtcNow().UtcDateTime;
ApiKeyLookup? lookup;
try
{
lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
// The contract forbids throwing: an exception here escapes into the SDK's session-activation
// path and surfaces as BadInternalError. A store outage denies, it does not crash.
_log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (lookup is null)
{
_log.LogInformation("ApiKeyAuth: denied — no enabled, unexpired key with id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
bool verified;
try
{
verified = ApiKeySecret.Verify(secret, opts.Pepper, lookup.Key.SecretHash);
}
catch (ArgumentException ex)
{
// Thrown only when the pepper is unset. This is a deployment error, not a bad credential —
// log it loudly, because otherwise it presents as "every key is rejected" with no clue why.
_log.LogError(ex,
"ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is",
ApiKeyOptions.SectionName);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (!verified)
{
_log.LogInformation("ApiKeyAuth: denied — secret mismatch for key id {ApiKeyId}", apiKeyId);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
if (string.Equals(lookup.Key.Principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
{
// Defence in depth against a key authored with the reserved principal (authoring blocks it).
// Allowing it would let a credentialled client silently inherit whatever anonymous was
// granted, and vice versa — two identities that are meant to be distinct sharing one grant.
_log.LogError(
"ApiKeyAuth: denied — key {ApiKeyId} uses the reserved principal '{Principal}'",
apiKeyId, OpcUaPrincipals.Anonymous);
return OpcUaUserAuthResult.Deny(DenyMessage);
}
var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal);
_log.LogInformation(
"ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]",
lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles));
return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles);
}
/// <inheritdoc />
/// <remarks>
/// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
/// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case.
/// Whether anonymous may connect at all is the host's decision
/// (<c>OpcUa:AllowAnonymous</c>) and is settled before this is called.
/// </remarks>
public async Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct)
{
IReadOnlyList<NodeAcl> grants;
try
{
grants = await _store.FindGrantsAsync(OpcUaPrincipals.Anonymous, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
// Deny rather than admit-with-no-roles. Admitting would be the pre-#520 behaviour, and doing
// it precisely when we could not read what anonymous is allowed to do is the wrong default:
// once reads are gated (stage 6) a no-roles admission stops being harmless.
_log.LogError(ex, "ApiKeyAuth: key store threw resolving anonymous grants");
return OpcUaUserAuthResult.Deny("Anonymous access is unavailable.");
}
var roles = ApiKeyInterimRoleMapper.Map(grants, OpcUaPrincipals.Anonymous);
_log.LogInformation(
"ApiKeyAuth: anonymous admitted with roles [{Roles}]",
roles.Count == 0 ? "none" : string.Join(",", roles));
return OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, roles);
}
}
@@ -0,0 +1,80 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
// Fully-qualified rather than relative: this file's own namespace root contains a `Configuration`
// namespace (ZB.MOM.WW.OtOpcUa.Host.Configuration), so a relative `Configuration.Enums` binds there and
// fails to compile.
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// <see cref="IApiKeyStore"/> served from the deployment artifact (#520, stage 4). This is the key
/// source for a <b>driver-only</b> node, which has had no ConfigDb since per-cluster mesh Phase 4 and
/// until now denied every UserName token.
/// </summary>
/// <remarks>
/// <para>
/// Purely in-memory: <see cref="OpcUaPublishActor"/> decodes the keys and grants once per applied
/// artifact and installs them on <see cref="IDataPlaneAuthoritySource"/>. That matters because the
/// SDK raises the impersonation callback <b>synchronously, under a <c>SessionManager</c>-wide
/// lock</b> — an I/O-bound lookup here would serialize every session activation in the server
/// behind one disk or network round trip.
/// </para>
/// <para>
/// ⚠️ <b>Consequence: revocation lands on the next deployment, not immediately.</b> A node reading
/// the ConfigDb sees <c>Enabled = 0</c> on the next request; this one sees it only once a new
/// artifact arrives. That asymmetry is inherent to a node with no config database and is
/// documented for operators on the AdminUI key page.
/// </para>
/// <para>
/// Before the first artifact is applied the snapshot is empty, so every key is denied. That is the
/// safe direction, and it is also honest — a node that has not been deployed to has no configured
/// keys.
/// </para>
/// </remarks>
public sealed class ArtifactApiKeyStore : IApiKeyStore
{
private readonly IDataPlaneAuthoritySource _authority;
/// <summary>Initializes a new instance of the <see cref="ArtifactApiKeyStore"/> class.</summary>
/// <param name="authority">The node's current data-plane authorization inputs.</param>
public ArtifactApiKeyStore(IDataPlaneAuthoritySource authority)
{
ArgumentNullException.ThrowIfNull(authority);
_authority = authority;
}
/// <inheritdoc />
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
// Read the snapshot ONCE. Re-reading Current for the grants would let a deploy land between the
// two reads and pair a key from one generation with grants from another.
var snapshot = _authority.Current;
var key = snapshot.FindUsableKey(apiKeyId, utcNow);
return Task.FromResult(key is null
? null
: new ApiKeyLookup(key, ClusterGrants(snapshot, key.Principal)));
}
/// <inheritdoc />
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult(ClusterGrants(_authority.Current, principal));
/// <summary>
/// Narrows a principal's grants to the Cluster-scoped ones, matching <c>ConfigDbApiKeyStore</c>.
/// Only those confer an interim role (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens
/// both stores to the full grant set once scope is actually enforced.
/// </summary>
private static IReadOnlyList<NodeAcl> ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal)
{
var all = snapshot.GrantsFor(principal);
if (all.Count == 0) return [];
var scoped = new List<NodeAcl>(all.Count);
foreach (var grant in all)
{
if (grant.ScopeKind == NodeAclScopeKind.Cluster) scoped.Add(grant);
}
return scoped;
}
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// <see cref="IApiKeyStore"/> reading the config database directly (#520, stage 2). Usable on a node
/// that has a ConfigDb — i.e. one carrying the <c>admin</c> role, including a fused admin+driver node.
/// </summary>
/// <remarks>
/// <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot be
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead (#520 stage 4).
/// <para>
/// Reading per request is what makes revocation immediate here, which is why this store is
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStore : IApiKeyStore
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _factory;
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
/// <param name="factory">The config DB context factory.</param>
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory) => _factory = factory;
/// <inheritdoc />
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
// Enabled + expiry are filtered in SQL rather than in memory so a disabled key never
// materialises into a process where something might later treat it as a match.
var key = await db.ApiKeys
.AsNoTracking()
.SingleOrDefaultAsync(
k => k.ApiKeyId == apiKeyId
&& k.Enabled
&& (k.ExpiresUtc == null || k.ExpiresUtc > utcNow),
ct)
.ConfigureAwait(false);
if (key is null) return null;
return new ApiKeyLookup(key, await LoadClusterGrantsAsync(db, key.Principal, ct).ConfigureAwait(false));
}
/// <inheritdoc />
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
}
private static async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
OtOpcUaConfigDbContext db, string principal, CancellationToken ct)
{
// Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
// those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
return await db.NodeAcls
.AsNoTracking()
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
.ToListAsync(ct)
.ConfigureAwait(false);
}
}
@@ -0,0 +1,43 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// Source of API keys and their ACL grants for OPC UA session activation (#520).
/// </summary>
/// <remarks>
/// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused
/// admin+driver node reads the config database directly (<see cref="ConfigDbApiKeyStore"/>); a
/// <b>driver-only</b> node has no ConfigDb at all since per-cluster mesh Phase 4, so its keys come out
/// of the deployment artifact (<see cref="ArtifactApiKeyStore"/>).
/// <para>
/// The two differ in one operator-visible way: the ConfigDb store sees a revocation on the next
/// request, the artifact store only once a new deployment reaches the node.
/// </para>
/// </remarks>
public interface IApiKeyStore
{
/// <summary>
/// Looks up an enabled, unexpired key by its public id, together with the ACL grants naming that
/// key's principal.
/// </summary>
/// <param name="apiKeyId">The public key id parsed from the presented credential.</param>
/// <param name="utcNow">Current UTC time, for the expiry check.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The key and its principal's grants, or null when no usable key matches.</returns>
Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct);
/// <summary>
/// Returns the ACL grants naming a principal, with no key involved. Used for the
/// <c>anonymous</c> principal, which has grants but obviously no credential.
/// </summary>
/// <param name="principal">The principal to fetch grants for.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The matching ACL rows; empty when none.</returns>
Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct);
}
/// <summary>An API key resolved for authentication, with the ACL grants naming its principal.</summary>
/// <param name="Key">The matched key row.</param>
/// <param name="Grants">ACL rows whose principal matches <see cref="ApiKey.Principal"/>.</param>
public sealed record ApiKeyLookup(ApiKey Key, IReadOnlyList<NodeAcl> Grants);
@@ -1,260 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
/// session identities. Roles are resolved through the shared
/// <see cref="IGroupRoleMapper{TRole}"/> seam from the LDAP groups returned by the directory —
/// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA
/// session identity for the downstream data-plane ACL evaluator.
/// </summary>
/// <remarks>
/// <para>
/// This authenticator is registered as a singleton, but <see cref="IGroupRoleMapper{TRole}"/>
/// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to
/// resolve the mapper so the singleton never captures a scoped dependency.
/// </para>
/// <para>
/// <b>Resilience (arch-review 03/S2).</b> The OPC UA SDK raises the impersonation callback that
/// reaches here <em>synchronously</em>, inside a <c>SessionManager</c>-wide event lock (verified
/// against <c>OPCFoundation.NetStandard.Opc.Ua.Server</c> 1.5.378 —
/// <c>SessionManager.ActivateSessionAsync</c> holds <c>m_eventLock</c> while invoking the
/// <c>void</c> impersonation delegate). A slow / hung LDAP bind inside that callback therefore does
/// not merely stall one SDK thread — it serializes <em>every</em> session activation server-wide
/// (Anonymous and X509 included). The callback contract is irreducibly synchronous (there is no
/// async impersonation callback), so the fix lives entirely at this boundary: a hard whole-flow
/// wall-clock bound (<see cref="LdapOptions.AuthTimeoutMs"/>) and a bounded in-flight concurrency cap
/// (<see cref="LdapOptions.MaxConcurrentAuthentications"/>), both fail-closed. All new paths deny.
/// </para>
/// </remarks>
public sealed class LdapOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
private readonly ILdapAuthService _ldap;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<LdapOpcUaUserAuthenticator> _logger;
private readonly LdapOptions _options;
/// <summary>Bounds in-flight authentication flows. A boundary timeout abandons its core task, which
/// keeps holding its slot until it actually completes — so an outage cannot accumulate unbounded
/// orphan binds.</summary>
private readonly SemaphoreSlim _gate;
/// <summary>Guards the outage-circuit state (<see cref="_consecutiveSystemFailures"/> +
/// <see cref="_circuitOpenUntilTicks"/>).</summary>
private readonly object _circuitLock = new();
/// <summary>Count of CONSECUTIVE system-side failures; reset by any success / user-side deny.</summary>
private int _consecutiveSystemFailures;
/// <summary><see cref="Environment.TickCount64"/> the circuit stays open until; <c>0</c> = closed.</summary>
private long _circuitOpenUntilTicks;
/// <summary>Constructs the authenticator from its dependencies and the bound LDAP options.</summary>
/// <param name="ldap">The app LDAP auth service (shared with the Admin UI login flow).</param>
/// <param name="scopeFactory">Opens a per-call DI scope to resolve the scoped role mapper.</param>
/// <param name="options">The bound <see cref="LdapOptions"/> (timeout + concurrency knobs).</param>
/// <param name="logger">The logger.</param>
public LdapOpcUaUserAuthenticator(
ILdapAuthService ldap,
IServiceScopeFactory scopeFactory,
IOptions<LdapOptions> options,
ILogger<LdapOpcUaUserAuthenticator> logger)
{
_ldap = ldap;
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
_gate = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentAuthentications));
}
/// <inheritdoc />
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
var authTimeout = TimeSpan.FromMilliseconds(Math.Max(1, _options.AuthTimeoutMs));
// Directory-outage circuit: after a run of consecutive system-side failures, fail fast WITHOUT
// touching LDAP for the cooldown window — so a sustained outage cannot serialize every session
// activation behind a per-call stall (the SDK raises this callback under a server-wide lock).
if (IsCircuitOpen())
{
_logger.LogDebug(
"LDAP outage circuit open — fast-denying OPC UA user {User} without a directory call", username);
return OpcUaUserAuthResult.Deny("Authentication backend unavailable");
}
// Bounded in-flight concurrency: acquire a slot within our own wall-clock budget. All slots
// busy is LOCAL backpressure, not directory evidence — deny "busy" (fail-closed) and do NOT
// feed the outage circuit.
if (!await _gate.WaitAsync(authTimeout, ct).ConfigureAwait(false))
{
_logger.LogWarning(
"LDAP authentication backend busy (concurrency cap {Cap}) — denying OPC UA user {User}",
_options.MaxConcurrentAuthentications, username);
return OpcUaUserAuthResult.Deny("Authentication backend busy");
}
// Release the slot when the CORE task completes — deliberately NOT in a finally here. A boundary
// timeout below abandons the core task (it runs to completion in the background; the underlying
// library is documented never-throw, so there are no unobserved exceptions), and it must keep
// counting against the cap until it finishes. The point of the boundary is to release the SDK
// thread (and m_eventLock), not to cancel the bind.
var core = AuthenticateCoreAsync(username, password, ct);
_ = core.ContinueWith(
static (_, state) => ((SemaphoreSlim)state!).Release(),
_gate,
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
try
{
var outcome = await core.WaitAsync(authTimeout, ct).ConfigureAwait(false);
RecordOutcome(outcome.SystemFailure, username);
return outcome.Result;
}
catch (TimeoutException)
{
// A boundary timeout is directory evidence — it feeds the circuit.
RecordOutcome(systemFailure: true, username);
_logger.LogWarning(
"LDAP authentication timed out after {Timeout} for OPC UA user {User} — denying (fail-closed)",
authTimeout, username);
return OpcUaUserAuthResult.Deny("Authentication timed out");
}
}
/// <summary>Whether the outage circuit is currently open (fast-deny window active). Always false when
/// the circuit is disabled (<see cref="LdapOptions.OutageFailureThreshold"/> ≤ 0).</summary>
private bool IsCircuitOpen()
{
if (_options.OutageFailureThreshold <= 0) return false;
lock (_circuitLock)
{
return _circuitOpenUntilTicks > Environment.TickCount64;
}
}
/// <summary>
/// Updates the outage-circuit state from one authentication outcome. A system-side failure advances
/// the consecutive-failure streak and opens the circuit once it reaches
/// <see cref="LdapOptions.OutageFailureThreshold"/>; any non-system outcome (success or user-side
/// deny) resets the streak and closes the circuit. Half-open behaviour is implicit: once the cooldown
/// elapses <see cref="IsCircuitOpen"/> lets the next call through with the streak still at threshold,
/// so a single further system failure re-opens immediately.
/// </summary>
/// <param name="systemFailure">Whether this outcome was a system-side (directory) failure.</param>
/// <param name="username">The login name, for diagnostics.</param>
private void RecordOutcome(bool systemFailure, string username)
{
if (_options.OutageFailureThreshold <= 0) return; // circuit disabled
lock (_circuitLock)
{
if (systemFailure)
{
_consecutiveSystemFailures++;
if (_consecutiveSystemFailures >= _options.OutageFailureThreshold)
{
var wasOpen = _circuitOpenUntilTicks > Environment.TickCount64;
_circuitOpenUntilTicks =
Environment.TickCount64 + (long)Math.Max(1, _options.OutageCooldownSeconds) * 1000L;
if (!wasOpen)
{
_logger.LogWarning(
"LDAP outage circuit OPENED after {Failures} consecutive system-side failures — " +
"fast-denying for {Cooldown}s (last user {User})",
_consecutiveSystemFailures, _options.OutageCooldownSeconds, username);
}
}
}
else
{
if (_consecutiveSystemFailures >= _options.OutageFailureThreshold)
{
_logger.LogInformation(
"LDAP outage circuit CLOSED — directory recovered (probe succeeded for {User})", username);
}
_consecutiveSystemFailures = 0;
_circuitOpenUntilTicks = 0;
}
}
}
/// <summary>
/// The unbounded core authenticate flow (bind + role-map) that the outer
/// <see cref="AuthenticateUserNameAsync"/> wraps in a wall-clock bound + concurrency cap. Fail-closed:
/// any backend throw becomes a denial rather than escaping into the SDK. The returned
/// <see cref="CoreOutcome.SystemFailure"/> flags directory-side faults (a system-side
/// <see cref="LdapAuthResult.IsSystemFailure"/> deny, or an unexpected throw) so the caller can feed
/// the outage circuit; a user-credential deny is NOT a system failure.
/// </summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The cleartext password.</param>
/// <param name="ct">Cancellation token.</param>
private async Task<CoreOutcome> AuthenticateCoreAsync(string username, string password, CancellationToken ct)
{
try
{
var result = await _ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
if (!result.Success)
{
return new CoreOutcome(
OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials"), result.IsSystemFailure);
}
var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false);
return new CoreOutcome(OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles), SystemFailure: false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username);
return new CoreOutcome(OpcUaUserAuthResult.Deny("Authentication backend error"), SystemFailure: true);
}
}
/// <summary>The result of the core authenticate flow plus whether it was a system-side (directory)
/// failure — the signal that drives the outage circuit.</summary>
/// <param name="Result">The auth result to return to the SDK.</param>
/// <param name="SystemFailure">Whether the outcome was a directory-side fault.</param>
private readonly record struct CoreOutcome(OpcUaUserAuthResult Result, bool SystemFailure);
/// <summary>
/// Resolves the user's roles from their LDAP groups via the scoped
/// <see cref="IGroupRoleMapper{TRole}"/>, unioned with any pre-resolved roles (the DevStub
/// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated
/// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour.
/// </summary>
/// <param name="groups">The LDAP groups returned by the directory.</param>
/// <param name="preResolved">Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).</param>
/// <param name="username">The login name, for diagnostics.</param>
/// <param name="ct">Cancellation token.</param>
private async Task<IReadOnlyList<string>> ResolveRolesAsync(
IReadOnlyList<string> groups, IReadOnlyList<string> preResolved, string username, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);
var roles = new HashSet<string>(preResolved, StringComparer.OrdinalIgnoreCase);
foreach (var role in mapping.Roles)
roles.Add(role);
return [.. roles];
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex,
"Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username);
return preResolved;
}
}
}
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
@@ -34,6 +35,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly IHistorianDataSource _historianDataSource;
private readonly IDataPlaneAuthoritySource _dataPlaneAuthority;
private readonly ServerHistorianOptions _serverHistorianOptions;
private readonly Func<ActorSystem> _actorSystemAccessor;
private readonly ActorRegistry _actorRegistry;
@@ -55,6 +57,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// node, the same source the address-space sink + node-write gateway come from), or the configured
/// HistorianGateway read client when <c>AddServerHistorian</c> enabled it. Wired onto the node manager in
/// <see cref="StartAsync"/>.</param>
/// <param name="dataPlaneAuthority">#520 stage 4 — the node's data-plane authorization inputs (its
/// cluster, the current auth generation, and the ACL grants), refreshed from every applied deployment
/// artifact. Wired onto the node manager in <see cref="StartAsync"/> so the stage 5/6 enforcement seams
/// have a cluster identity to build a <c>NodeScope</c> with. Resolves to the empty source on a node with
/// no driver role.</param>
/// <param name="configuration">App configuration; the <c>ServerHistorian</c> section is bound here to
/// read <see cref="ServerHistorianOptions.MaxTieClusterOverfetch"/> for the node manager. Bound directly
/// (not via <c>IOptions</c>) so the wiring is self-contained in this hosted service — the option's
@@ -73,6 +80,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
IHistorianDataSource historianDataSource,
IDataPlaneAuthoritySource dataPlaneAuthority,
IConfiguration configuration,
Func<ActorSystem> actorSystemAccessor,
ActorRegistry actorRegistry,
@@ -83,6 +91,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
_historianDataSource = historianDataSource;
_dataPlaneAuthority = dataPlaneAuthority;
// Bind the ServerHistorian section directly (defaults survive when it's absent) — we only need
// MaxTieClusterOverfetch here, and binding it ourselves keeps the wiring in this one file.
_serverHistorianOptions =
@@ -216,6 +225,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
_server.SetHistorianDataSource(_historianDataSource);
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. Nothing reads
// them until the stage 5/6 enforcement seams land, so this changes no behaviour today; it exists
// because the node manager had NO cluster identity, and NodeScope.ClusterId is required while the
// evaluator fails closed on a mismatch — every scope it could have built would have been denied.
_server.SetDataPlaneAuthority(_dataPlaneAuthority);
// Propagate the configured tie-cluster over-fetch bound onto the node manager so HistoryRead-Raw
// can page WITHIN an oversized tie cluster (more samples sharing one SourceTimestamp than the page
// cap) rather than failing the read. The option's default (65536) survives when the ServerHistorian
@@ -257,6 +272,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_server?.SetNativeAlarmAckRouter(null);
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
_server?.SetHistorianDataSource(null);
// Restore the empty authority so a late session activation cannot authorize against a stopping node.
_server?.SetDataPlaneAuthority(null);
return Task.CompletedTask;
}
+60 -1
View File
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Serilog;
using ZB.MOM.WW.LocalDb.Replication;
@@ -20,6 +21,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
@@ -132,6 +134,11 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
// central over the Phase-3 fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it
// unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string.
// A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected.
// #520 — bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it
// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a
// startup error.
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
if (hasAdmin)
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
else
@@ -339,7 +346,59 @@ if (hasDriver)
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
builder.Services.TryAddScoped<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
// #520 stage 2 — the OPC UA DATA PLANE is machine-to-machine, so its identity is an API key
// (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP
// registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP.
// Only IOpcUaUserAuthenticator moves.
// #520 stage 4 — the node's data-plane authorization inputs (keys + ACL grants + cluster + auth
// generation), refreshed by OpcUaPublishActor from every applied artifact. Registered on any
// driver-role node because that is what serves an OPC UA data plane; the two interfaces resolve to
// the SAME instance, since the writer and the readers must see one snapshot.
if (hasDriver)
{
builder.Services.AddSingleton<DataPlaneAuthorityState>();
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
builder.Services.AddSingleton<IDataPlaneAuthoritySink>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
}
else
{
// The SOURCE half must always resolve — OtOpcUaServerHostedService takes it as a required ctor
// parameter, and the built-in DI container does not fall back to a default value for a parameter
// whose service is unregistered; it throws and the node fails to start. Same trap, and the same
// fix, as the NullOpcUaUserAuthenticator registration below. Only the SINK is genuinely optional
// (nothing publishes artifacts on a node with no driver role).
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
}
if (hasAdmin)
{
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
// on the very next request rather than on the next deployment. Preferred wherever it is available,
// including on a fused admin+driver node.
builder.Services.AddSingleton<IApiKeyStore, ConfigDbApiKeyStore>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
builder.Services.AddSingleton<IApiKeyAdminService>(sp => new ApiKeyAdminService(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
}
else if (hasDriver)
{
// Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact.
// Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName
// token — safe, but it meant no machine client could authenticate against a site node at all.
builder.Services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
}
else
{
// Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent —
// OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so
// an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start.
builder.Services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
}
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -51,6 +52,29 @@ public sealed class OpcUaApplicationHostOptions
/// </summary>
public string PkiStoreRoot { get; set; } = "pki";
/// <summary>
/// Whether anonymous sessions may connect at all (#520). When <see langword="false"/> the
/// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client
/// must present an API key.
/// </summary>
/// <remarks>
/// <para>
/// <b>Defaults to <see langword="true"/> deliberately, which preserves today's behaviour.</b>
/// Flipping the default would silently deny every client that has no key the moment this
/// code deploys — including ScadaBridge's Data Connection Layer, which consumes this address
/// space from another repo. Turning it off is the deployment-time decision that makes keys
/// mandatory, and it belongs in the rollout once keys have been issued.
/// </para>
/// <para>
/// Note this controls <i>admission</i>, not authority. An admitted anonymous session is a
/// normal principal (<c>anonymous</c>) whose grants come from <c>NodeAcl</c> rows naming it,
/// exactly like a key's principal — there is no anonymous bypass branch in the evaluator.
/// Until per-node enforcement lands, an admitted session of ANY kind can still read the whole
/// address space, because reads carry no authorization check yet.
/// </para>
/// </remarks>
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// Transport-security profiles exposed by the server. The SDK publishes one endpoint
/// descriptor per profile and clients choose at session open. Default = all three
@@ -228,7 +252,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
}
private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
HandleImpersonation(_userAuthenticator, args, _logger);
HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous);
/// <summary>
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
@@ -246,7 +270,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// (<c>GetAwaiter().GetResult()</c> under <see cref="CancellationToken.None"/>) is irreducible: the
/// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded
/// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live
/// inside the authenticator (<c>LdapOpcUaUserAuthenticator</c>, arch-review 03/S2) — NOT here.
/// inside the authenticator — NOT here. (#520 replaced the LDAP authenticator with
/// <c>ApiKeyOpcUaUserAuthenticator</c>, whose verification does no network I/O at all, so the
/// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.)
/// </remarks>
/// <param name="authenticator">The user authenticator to validate credentials.</param>
/// <param name="args">The impersonation event arguments to process.</param>
@@ -254,11 +280,18 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
internal static void HandleImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger)
ILogger logger,
bool allowAnonymous = true)
{
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
{
HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken);
return;
}
if (args.NewIdentity is not UserNameIdentityToken token)
{
// Anonymous + X509 tokens — let the SDK's default validation stand.
// X509 tokens — let the SDK's default validation stand.
return;
}
@@ -307,6 +340,66 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
token.UserName, string.Join(",", result.Roles));
}
/// <summary>
/// Admits or rejects an anonymous session, and — when admitted — attaches the roles the
/// <c>anonymous</c> principal has been granted (#520).
/// </summary>
/// <remarks>
/// Before #520 this path simply returned, leaving the SDK's default identity in place: anonymous
/// always connected and carried no roles. Two things change. Anonymous can now be refused
/// outright, and when admitted it is a normal principal whose grants come from ACL rows, so it
/// can be given read or write like any key. The identity attached is the same
/// <see cref="RoleCarryingUserIdentity"/> the UserName path uses, so every downstream gate reads
/// it identically and there is no anonymous-shaped branch in any of them.
/// </remarks>
private static void HandleAnonymousImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger,
bool allowAnonymous,
AnonymousIdentityToken token)
{
if (!allowAnonymous)
{
logger.LogInformation(
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is disabled on this server.");
return;
}
OpcUaUserAuthResult result;
try
{
// Same synchronous bridge, and for the same irreducible reason, as the UserName path above.
result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
// Fail closed. An anonymous session whose grants could not be resolved must not fall through
// to the SDK default identity, because that would silently ADMIT it — the pre-#520
// behaviour — at exactly the moment we could not determine what it is allowed to do.
logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is unavailable.");
return;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
result.Error ?? "Anonymous access is denied.");
return;
}
args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})",
OpcUaPrincipals.Anonymous,
result.Roles.Count == 0 ? "no roles" : string.Join(",", result.Roles));
}
/// <summary>
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
@@ -348,7 +441,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
serverConfig.SecurityPolicies.Add(policy);
}
foreach (var token in BuildUserTokenPolicies())
foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous))
{
serverConfig.UserTokenPolicies.Add(token);
}
@@ -440,13 +533,20 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// UserName logins when none is registered.
/// </summary>
/// <returns>The Anonymous and UserName token policies.</returns>
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies(bool allowAnonymous = true)
{
yield return new UserTokenPolicy(UserTokenType.Anonymous)
// Withholding the policy is what makes the refusal visible to a client at DISCOVERY rather than
// only at activation: it simply is not offered an anonymous option. HandleImpersonation still
// rejects anonymous activation independently, because a client may attempt a token type the
// endpoint never advertised.
if (allowAnonymous)
{
PolicyId = "anonymous",
SecurityPolicyUri = SecurityPolicies.None,
};
yield return new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "anonymous",
SecurityPolicyUri = SecurityPolicies.None,
};
}
yield return new UserTokenPolicy(UserTokenType.UserName)
{
PolicyId = "username_basic256sha256",
@@ -3,6 +3,7 @@ using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
@@ -243,6 +244,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
}
/// <summary>
/// #520 stage 4 — the node's current data-plane authorization inputs: which cluster it serves,
/// the generation those were resolved at, and the ACL grants themselves.
/// </summary>
/// <remarks>
/// <para>
/// This closes the fourth #520 blocker: <c>NodeScope.ClusterId</c> is <c>required</c> and
/// <c>TriePermissionEvaluator</c> fails closed when it disagrees with the session's, but the
/// node manager had no idea what cluster it was serving — so no <c>NodeScope</c> it built
/// could ever have evaluated. It is fed here rather than at construction because the SDK
/// constructs this manager during <c>StartAsync</c>, before any artifact has been applied,
/// and the value changes on every deploy.
/// </para>
/// <para>
/// Stage 4 only supplies it. The enforcement seams that consume it — the write gate, the alarm
/// gates, HistoryRead, and (stage 6) read/browse/subscribe — land in stages 5 and 6, so
/// nothing reads this yet and authorization behaviour is unchanged.
/// </para>
/// <para>
/// Backed by a <c>volatile</c> field for the same reason as <see cref="HistorianDataSource"/>:
/// written on the deploy path, read on SDK request threads. Assigning <c>null</c> restores the
/// empty source, which authenticates and authorizes nothing.
/// </para>
/// </remarks>
public IDataPlaneAuthoritySource DataPlaneAuthority
{
get => _dataPlaneAuthority;
set => _dataPlaneAuthority = value ?? EmptyDataPlaneAuthoritySource.Instance;
}
private volatile IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
/// <summary>
/// The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> uses to page WITHIN an
/// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
@@ -2,6 +2,7 @@ using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -103,6 +104,28 @@ public sealed class OtOpcUaSdkServer : StandardServer
return true;
}
/// <summary>
/// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created
/// <see cref="OtOpcUaNodeManager"/> — the cluster it serves, the current auth generation, and the
/// ACL grants. The host calls this after start with the DI-resolved
/// <see cref="IDataPlaneAuthoritySource"/>, which the artifact-apply path refreshes on every
/// deploy. Passing <c>null</c> restores the empty source (authorizes nothing). No-op (returns
/// <c>false</c>) when the node manager has not been created yet, so the caller can detect a
/// too-early call (mirrors <see cref="SetHistorianDataSource"/>).
/// </summary>
/// <param name="source">The node's data-plane authorization inputs; may be <c>null</c> to restore the
/// empty default.</param>
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
/// manager exists yet.</returns>
public bool SetDataPlaneAuthority(IDataPlaneAuthoritySource? source)
{
if (_otOpcUaNodeManager is null) return false;
// The DataPlaneAuthority setter null-coalesces to the empty source, so a null is intentional;
// forgive the nullable-in here.
_otOpcUaNodeManager.DataPlaneAuthority = source!;
return true;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// <summary>
@@ -22,8 +24,32 @@ public interface IOpcUaUserAuthenticator
/// <param name="ct">The cancellation token.</param>
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
/// <summary>
/// Resolves what an <b>anonymous</b> session is allowed to do (#520). Anonymous is a normal
/// principal — <see cref="OpcUaPrincipals.Anonymous"/> — not a special case, so it can be granted
/// read or write through ordinary ACL rows.
/// </summary>
/// <remarks>
/// <para>
/// This is asked only AFTER the host has decided anonymous sessions are admissible at all
/// (<c>OpcUaApplicationHostOptions.AllowAnonymous</c>). Admission is the host's decision;
/// authority is this seam's.
/// </para>
/// <para>
/// The default implementation admits anonymous with <b>no roles</b>, which is exactly the
/// behaviour before #520: an anonymous session connects, and every role-gated operation
/// (write, alarm ack) denies. Implementors that can resolve grants override it; the rest
/// keep the safe status quo rather than being forced to reimplement it.
/// </para>
/// </remarks>
/// <param name="ct">The cancellation token.</param>
/// <returns>The roles to attach to the anonymous session, or a denial.</returns>
Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct) =>
Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, Array.Empty<string>()));
}
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
public sealed record OpcUaUserAuthResult(
bool Success,
@@ -19,6 +19,12 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<!-- #520: the node manager is where ACL enforcement has to happen, and until now Core was not on
this project's reference graph at all — IPermissionEvaluator was literally uncallable from
here. Referenced ahead of the enforcement stages so the seams can be wired incrementally.
Adds no new transitive package surface: Core pulls Abstractions/Commons/Configuration, all
three of which are already referenced above. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -37,6 +37,24 @@ public enum ClusterFilterMode
Suppress,
}
/// <summary>
/// The data-plane authorization inputs decoded out of a deployment artifact for one node (#520).
/// </summary>
/// <param name="ClusterId">The node's cluster per its <c>ClusterNode</c> row, or null on a
/// single-cluster / legacy artifact where no scoping applies.</param>
/// <param name="Keys">Every <c>ApiKey</c> the artifact carries, revoked and expired included — the
/// lookup filters those, so that a revocation is visible rather than indistinguishable from a
/// missing key.</param>
/// <param name="Grants">The <c>NodeAcl</c> rows scoped to <paramref name="ClusterId"/>.</param>
public sealed record DataPlaneAuthorityProjection(
string? ClusterId,
IReadOnlyList<ApiKey> Keys,
IReadOnlyList<NodeAcl> Grants)
{
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty = new(null, [], []);
}
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
/// <param name="Mode">
/// None = apply everything (single-cluster / legacy); ScopeTo = filter to <paramref name="ClusterId"/>;
@@ -534,6 +552,170 @@ public static class DeploymentArtifact
: all;
}
/// <summary>
/// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions:
/// the <c>ApiKeys</c> the artifact carries and the <c>NodeAcls</c> granting their principals
/// permissions (#520, stage 4).
/// </summary>
/// <remarks>
/// <para>
/// This is the ONLY key source a <b>driver-only</b> node has — per-cluster mesh Phase 4 left
/// it with no ConfigDb, so without this it denies every UserName token.
/// </para>
/// <para>
/// Grants are scoped to the node's own cluster (<see cref="ResolveClusterScope"/>); keys are
/// not, because <c>ApiKey</c> carries no ClusterId — a key is a fleet-wide credential and it
/// is the ACL rows that bind it to a cluster's nodes. A <see cref="ClusterFilterMode.Suppress"/>
/// scope — the node's row is absent from a multi-cluster artifact — yields NO grants, matching
/// the driver-spawn path's reading that an unlisted node was not deployed to.
/// </para>
/// <para>
/// Lenient like every other parser here: an empty or malformed blob yields empty lists. The
/// caller must NOT treat that as "no keys are valid" and install it — see issue #485; the
/// apply paths only reach this with bytes that already passed their own emptiness guard.
/// </para>
/// </remarks>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <param name="nodeId">The node's identity, used to resolve cluster scope for the grants.</param>
/// <returns>The node's cluster (null when single-cluster), its keys, and its cluster's grants.</returns>
public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan<byte> blob, string nodeId)
{
if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty;
var scope = ResolveClusterScope(blob, nodeId);
try
{
using var doc = JsonDocument.Parse(blob.ToArray());
var root = doc.RootElement;
var keys = new List<ApiKey>();
foreach (var el in EnumerateArray(root, "ApiKeys"))
{
var key = TryReadApiKey(el);
if (key is not null) keys.Add(key);
}
var grants = new List<NodeAcl>();
foreach (var el in EnumerateArray(root, "NodeAcls"))
{
var acl = TryReadNodeAcl(el);
if (acl is null) continue;
if (scope.Mode == ClusterFilterMode.Suppress) continue;
if (scope.Mode == ClusterFilterMode.ScopeTo
&& !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
grants.Add(acl);
}
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants);
}
catch (JsonException)
{
return DataPlaneAuthorityProjection.Empty;
}
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
/// missing, so a half-written row can never authenticate.</summary>
private static ApiKey? TryReadApiKey(JsonElement el)
{
var apiKeyId = ReadString(el, "ApiKeyId");
var secretHash = ReadString(el, "SecretHash");
var principal = ReadString(el, "Principal");
if (string.IsNullOrWhiteSpace(apiKeyId)
|| string.IsNullOrWhiteSpace(secretHash)
|| string.IsNullOrWhiteSpace(principal))
{
return null;
}
return new ApiKey
{
ApiKeyId = apiKeyId,
Name = ReadString(el, "Name") ?? apiKeyId,
SecretHash = secretHash,
Principal = principal,
// A row whose Enabled flag did not survive serialisation is treated as REVOKED. The other
// direction would resurrect a revoked key out of a malformed artifact.
Enabled = ReadBool(el, "Enabled") ?? false,
CreatedUtc = ReadDateTime(el, "CreatedUtc") ?? default,
ExpiresUtc = ReadDateTime(el, "ExpiresUtc"),
Notes = ReadNullableString(el, "Notes"),
};
}
/// <summary>Reads one <c>NodeAcls</c> element; null when identity, cluster or scope kind is unreadable.</summary>
private static NodeAcl? TryReadNodeAcl(JsonElement el)
{
var nodeAclId = ReadString(el, "NodeAclId");
var clusterId = ReadString(el, "ClusterId");
var principal = ReadString(el, "Principal");
if (string.IsNullOrWhiteSpace(nodeAclId)
|| string.IsNullOrWhiteSpace(clusterId)
|| string.IsNullOrWhiteSpace(principal))
{
return null;
}
// ConfigComposer serialises enums as NUMBERS (its JsonSerializerOptions carries no
// JsonStringEnumConverter), so these read as int. Keep the two sides in step — a string-enum
// converter added to the composer would silently drop every grant here.
var scopeKind = ReadInt32(el, "ScopeKind");
if (scopeKind is null || !Enum.IsDefined(typeof(NodeAclScopeKind), scopeKind.Value)) return null;
return new NodeAcl
{
NodeAclId = nodeAclId,
ClusterId = clusterId,
Principal = principal,
ScopeKind = (NodeAclScopeKind)scopeKind.Value,
ScopeId = ReadNullableString(el, "ScopeId"),
// An unreadable bitmask grants nothing rather than defaulting to something permissive.
PermissionFlags = (NodePermissions)(ReadInt32(el, "PermissionFlags") ?? 0),
Notes = ReadNullableString(el, "Notes"),
};
}
/// <summary>Reads a boolean property; null when absent or not a JSON boolean.</summary>
private static bool? ReadBool(JsonElement el, string property) =>
el.TryGetProperty(property, out var p)
? p.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null,
}
: null;
/// <summary>Reads an int property; null when absent, not a number, or out of int range.</summary>
private static int? ReadInt32(JsonElement el, string property) =>
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.Number
&& p.TryGetInt32(out var v)
? v
: null;
/// <summary>Reads a UTC DateTime property; null when absent, JSON null, or unparseable.</summary>
private static DateTime? ReadDateTime(JsonElement el, string property)
{
if (!el.TryGetProperty(property, out var p) || p.ValueKind != JsonValueKind.String) return null;
if (!p.TryGetDateTime(out var v)) return null;
// Normalise to UTC, because every consumer compares against DateTime.UtcNow and an expiry that
// silently shifted by the node's timezone is a key that outlives (or predeceases) its expiry.
// Unspecified must be SpecifyKind, NOT ToUniversalTime: EF materialises datetime2 as Unspecified,
// so the composer writes these UTC instants without an offset and ToUniversalTime would subtract
// the node's local offset from a value that was already UTC.
return v.Kind switch
{
DateTimeKind.Utc => v,
DateTimeKind.Local => v.ToUniversalTime(),
_ => DateTime.SpecifyKind(v, DateTimeKind.Utc),
};
}
/// <summary>Reads a string property, tolerating a wrong JSON type: a property that is present but
/// NOT a JSON string (number / bool / object / array) yields null rather than throwing
/// <see cref="InvalidOperationException"/> from <c>GetString()</c>. Keeps the artifact-decode lenient
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
@@ -95,6 +96,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private readonly NodeId? _localNode;
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
private readonly AddressSpaceApplier? _applier;
/// <summary>#520 stage 4 — receives the API keys + ACL grants carried by each applied artifact, so
/// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node
/// that is not serving an OPC UA data plane).</summary>
private readonly IDataPlaneAuthoritySink? _dataPlaneAuthority;
private readonly IActorRef? _dbHealthProbe;
/// <summary>Per-cluster mesh Phase 4: this is a driver-only node with NO ConfigDb (LocalDb is its
/// config store). There is no DB to probe, so the ServiceLevel DB-reachability input is retired —
@@ -166,7 +171,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false) =>
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -178,7 +184,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
staleWindow,
probeFreshnessWindow,
healthTickInterval,
dbLess)).WithDispatcher(DispatcherId);
dbLess,
dataPlaneAuthority)).WithDispatcher(DispatcherId);
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
@@ -214,7 +221,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false) =>
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -226,7 +234,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
staleWindow,
probeFreshnessWindow,
healthTickInterval,
dbLess));
dbLess,
dataPlaneAuthority));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
@@ -259,9 +268,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null,
bool dbLess = false)
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = null)
{
_sink = sink;
_dataPlaneAuthority = dataPlaneAuthority;
_serviceLevel = serviceLevel;
_subscribeRedundancyTopic = subscribeRedundancyTopic;
_localNode = localNode;
@@ -412,6 +423,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
return;
}
// #520 stage 4 — install this artifact's API keys + ACL grants BEFORE the address-space work
// below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a
// key or edits a grant without touching a single node produces an EMPTY address-space plan, so
// feeding this after that return would leave a revoked key authenticating until some unrelated
// structural change happened to redeploy. Authorization and topology change on their own
// schedules.
FeedDataPlaneAuthority(artifact);
var composition = _localNode is { } ln
? DeploymentArtifact.ParseComposition(artifact, ln.Value,
inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency))
@@ -491,6 +510,43 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
/// <summary>
/// Decode the artifact's data-plane authorization inputs (API keys + this cluster's ACL grants)
/// and install them for OPC UA session activation (#520 stage 4). No-op when nothing consumes
/// them.
/// </summary>
/// <remarks>
/// Wrapped so a decode failure can never break a deploy — the same discipline as the historised-ref
/// feed. The cost of that choice is explicit: the node keeps serving the PREVIOUS key set, which is
/// the safe direction for availability but means a revocation carried by an artifact this node
/// could not decode has not taken effect. It is logged at Error for exactly that reason.
/// </remarks>
private void FeedDataPlaneAuthority(byte[] artifact)
{
if (_dataPlaneAuthority is null) return;
try
{
var nodeId = _localNode?.Value ?? string.Empty;
var projection = DeploymentArtifact.ParseDataPlaneAuthority(artifact, nodeId);
// The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses,
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants);
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants})",
snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12],
projection.Keys.Count, projection.Grants.Count);
}
catch (Exception ex)
{
_log.Error(ex,
"OpcUaPublish: failed to install data-plane authority from the applied artifact — the node keeps the PREVIOUS keys and grants, so any revocation in this deployment has NOT taken effect");
}
}
/// <summary>Read a specific deployment's artifact blob from ConfigDb (the one just applied,
/// which may not be Sealed yet). Empty array on any failure — <see cref="HandleRebuild"/> treats that as
/// "no answer" and abandons the rebuild, leaving the served address space untouched (issue #485).</summary>
@@ -17,6 +17,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
@@ -467,6 +468,11 @@ public static class ServiceCollectionExtensions
loggerFactory.CreateLogger<AddressSpaceApplier>(),
provisioning: provisioning,
historizedSubscriptions: historizedSubscriptions);
// #520 stage 4 — the sink that receives each applied artifact's API keys + ACL grants, so OPC
// UA session activation can authenticate. Optional: GetService (not GetRequiredService), because
// a node with no driver role never registers it and test harnesses build this graph without it;
// the actor treats null as "nothing consumes authorization inputs" and skips the decode.
var dataPlaneAuthority = resolver.GetService<IDataPlaneAuthoritySink>();
var publishActor = system.ActorOf(
OpcUaPublishActor.Props(
sink: addressSpaceSink,
@@ -475,7 +481,8 @@ public static class ServiceCollectionExtensions
dbFactory: dbFactory,
applier: applier,
dbHealthProbe: dbHealth,
dbLess: dbFactory is null),
dbLess: dbFactory is null,
dataPlaneAuthority: dataPlaneAuthority),
OpcUaPublishActorName);
registry.Register<OpcUaPublishActorKey>(publishActor);
@@ -21,6 +21,10 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
<!-- #520 stage 4: the artifact-apply path feeds IDataPlaneAuthoritySink. Core already arrives
transitively via OpcUaServer, but a transitive reference is not a stated dependency — make it
explicit so removing it from OpcUaServer does not silently break this one. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<!--
Concrete OtOpcUa.Driver.* assemblies are loaded reflectively at runtime (Phase 6,
Task 41+) and intentionally NOT project-referenced here. The driver contracts live in
@@ -88,44 +88,18 @@ public sealed class LdapOptions
/// per-operation search inside a single bind flow. Projected into the library's
/// <see cref="LibLdapOptions.ConnectionTimeoutMs"/> via <see cref="ToLibraryOptions"/>. The
/// default (10000) matches the library default, so behaviour is unchanged unless configured.
/// This is the FIRST line of defence against a directory outage; the whole-flow
/// <see cref="AuthTimeoutMs"/> is the backstop (03/S2).
/// </summary>
/// <remarks>
/// This is now the ONLY directory-outage bound. Its former backstops — <c>AuthTimeoutMs</c>,
/// <c>MaxConcurrentAuthentications</c>, <c>OutageFailureThreshold</c> and
/// <c>OutageCooldownSeconds</c> — were removed with <c>LdapOpcUaUserAuthenticator</c> in #520
/// (see the note on this type). They existed because an LDAP bind ran inline on OPC UA session
/// activation, under a SessionManager-wide lock; nothing on the remaining AdminUI login path has
/// that property, so they had no consumer left. Setting any of them in configuration is now
/// silently ignored rather than an error, which is how options binding treats unknown keys.
/// </remarks>
public int ConnectionTimeoutMs { get; set; } = 10_000;
/// <summary>
/// Hard whole-flow wall-clock bound (in milliseconds) the OPC UA data-plane authenticator
/// (<c>LdapOpcUaUserAuthenticator</c>) applies across the ENTIRE authenticate flow — bind plus
/// role-map — so the OPC UA SDK's synchronous impersonation callback (raised under a
/// <c>SessionManager</c>-wide event lock) can never be wedged by a slow / hung directory.
/// Deliberately &gt; <see cref="ConnectionTimeoutMs"/> (default 15000) so a healthy-but-slow
/// full flow is not cut off while an outage is bounded by the library timeout first (03/S2).
/// </summary>
public int AuthTimeoutMs { get; set; } = 15_000;
/// <summary>
/// Maximum number of in-flight OPC UA authentication flows the data-plane authenticator admits
/// at once. A caller that cannot acquire a slot within its budget is denied "backend busy"
/// (local backpressure, not counted against the outage circuit). Bounds accumulation of
/// orphaned / timed-out bind tasks during an outage (03/S2). Default 8.
/// </summary>
public int MaxConcurrentAuthentications { get; set; } = 8;
/// <summary>
/// Number of CONSECUTIVE system-side failures (boundary timeout, directory-unreachable result,
/// or unexpected throw) after which the data-plane authenticator's outage circuit opens and
/// denies instantly for <see cref="OutageCooldownSeconds"/> without touching LDAP. Any success
/// or user-side (credential) deny resets the counter. <c>0</c> disables the circuit (03/S2).
/// Default 3.
/// </summary>
public int OutageFailureThreshold { get; set; } = 3;
/// <summary>
/// How long (in seconds) the outage circuit stays open before a half-open probe is allowed
/// through. Only meaningful when <see cref="OutageFailureThreshold"/> &gt; 0. Default 15 (03/S2).
/// </summary>
public int OutageCooldownSeconds { get; set; } = 15;
/// <summary>
/// Projects the wire fields onto the shared <c>ZB.MOM.WW.Auth.Ldap</c>
/// <see cref="LibLdapOptions"/> the directory client consumes. App-only concerns
@@ -57,7 +57,7 @@ public static class ServiceCollectionExtensions
// Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it
// wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton
// LdapOpcUaUserAuthenticator on driver-role nodes. This is the app's ILdapAuthService: it
// AdminUI login on admin-role nodes. This is the app's ILdapAuthService: it
// adds the Enabled master switch + DevStubMode bypass on top of the shared ZB.MOM.WW.Auth.Ldap
// service. TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
// driver path) ends up with exactly one descriptor regardless of registration order.
@@ -65,7 +65,7 @@ public static class ServiceCollectionExtensions
// Shared ZB.MOM.WW.Auth group→role mapper seam. Wraps the existing RoleMapper.Map +
// RoleMapper.Merge logic; consumed by AuthEndpoints.cs (/auth/login) and by
// LdapOpcUaUserAuthenticator (OPC UA UserName session auth). Scoped to match
// AdminUI login (the OPC UA data plane uses API keys since #520). Scoped to match
// ILdapGroupRoleMappingService (DbContext-backed, registered Scoped) — a singleton here
// would capture the scoped DB service.
services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
@@ -32,7 +32,9 @@ public sealed class SchemaComplianceTests
// v3 Raw-tree tables (Namespace retired; RawFolder/TagGroup/UnsTagReference added)
"RawFolder", "TagGroup", "UnsTagReference",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag",
"NodeAcl", "ExternalIdReservation",
// ApiKey added in Gitea #520 — the OPC UA data plane is machine-to-machine, so a consuming
// system authenticates with a key resolving to a Principal that NodeAcl rows grant to.
"NodeAcl", "ApiKey", "ExternalIdReservation",
// DriverHostStatus was dropped in Gitea #521 — nothing ever wrote a row, and per-cluster
// mesh Phase 4 made the publisher its entity doc described unbuildable (a driver-only node
// has no ConfigDb connection). Per-host connectivity now rides DriverHealthChanged to /hosts.
@@ -0,0 +1,84 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Validation rules for minting API keys (#520). These run BEFORE any database work, so they are
/// covered without a ConfigDb: a null context factory proves the guard rejected the input rather
/// than reaching persistence (it would NullReferenceException if it got that far).
/// </summary>
public sealed class ApiKeyAdminServiceTests
{
private static ApiKeyAdminService Sut(string pepper = "unit-test-pepper") => new(null!, pepper);
[Fact]
public async Task The_reserved_anonymous_principal_cannot_be_minted_as_a_key()
{
// Otherwise a credentialled client silently inherits whatever anonymous was granted. Rejected
// here AND at authentication, because the two paths are reachable independently.
var ex = await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("sneaky", OpcUaPrincipals.Anonymous, null, null, default));
ex.Message.ShouldContain("reserved");
}
[Fact]
public async Task The_reserved_principal_check_is_case_insensitive()
{
// "Anonymous" must not slip past a case-sensitive comparison and become a distinct principal
// that the evaluator would then match case-INsensitively against anonymous's grants.
await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("sneaky", "ANONYMOUS", null, null, default));
}
[Theory]
[InlineData("", "principal")]
[InlineData(" ", "principal")]
public async Task A_blank_principal_is_rejected(string principal, string expectedParam)
{
var ex = await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("name", principal, null, null, default));
ex.ParamName.ShouldBe(expectedParam);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task A_blank_name_is_rejected(string name)
{
var ex = await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync(name, "some-principal", null, null, default));
ex.ParamName.ShouldBe("name");
}
[Fact]
public async Task A_principal_longer_than_the_column_is_rejected_rather_than_truncated()
{
// Silent truncation would create a key whose principal does not match the ACL row the operator
// authored, i.e. a key that authenticates and is granted nothing, with no visible cause.
var tooLong = new string('p', ApiKeyAdminService.MaxPrincipalLength + 1);
await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("name", tooLong, null, null, default));
}
[Fact]
public async Task An_expiry_in_the_past_is_rejected()
{
await Should.ThrowAsync<ArgumentException>(() =>
Sut().CreateAsync("name", "p", DateTime.UtcNow.AddMinutes(-1), null, default));
}
[Fact]
public async Task An_unconfigured_pepper_fails_before_anything_is_persisted()
{
// Generate() runs before the DbContext is opened precisely so this surfaces as a clear pepper
// error rather than a stored row whose hash nothing could ever verify.
await Should.ThrowAsync<ArgumentException>(() =>
Sut(pepper: "").CreateAsync("name", "p", null, null, default));
}
}
@@ -0,0 +1,118 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Covers the pure API-key format helpers (#520). These are the security-critical half of the
/// data-plane identity change, so they are pinned independently of any store or DI.
/// </summary>
public sealed class ApiKeySecretTests
{
private const string Pepper = "test-pepper-not-a-real-one";
[Fact]
public void A_generated_key_round_trips_through_parse_and_verify()
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
ApiKeySecret.TryParse(presented, out var parsedId, out var secret).ShouldBeTrue();
parsedId.ShouldBe(apiKeyId);
ApiKeySecret.Verify(secret, Pepper, hash).ShouldBeTrue();
}
[Fact]
public void The_generated_key_id_never_contains_the_separator()
{
// Regression guard. The secret is url-safe base64, whose alphabet maps '/' to '_'. If the key id
// used that same alphabet it could contain the character TryParse splits on, and the id would be
// silently truncated at a random position for roughly 1 key in 5 — an intermittent auth failure
// that only ever reproduces for SOME keys. Hence hex. 400 draws makes a '_' near-certain if the
// alphabet ever regresses to base64.
for (var i = 0; i < 400; i++)
{
var (_, apiKeyId, _) = ApiKeySecret.Generate(Pepper);
apiKeyId.ShouldNotContain("_", Case.Sensitive,
$"key id '{apiKeyId}' contains the separator — TryParse would truncate it");
}
}
[Fact]
public void A_secret_containing_the_separator_still_round_trips()
{
// The secret IS allowed to contain '_' (it is the whole remainder), so prove the split is
// first-two-underscores rather than split-on-every-underscore.
const string secret = "aa_bb_cc__dd";
var presented = $"otua_deadbeef_{secret}";
ApiKeySecret.TryParse(presented, out var id, out var parsed).ShouldBeTrue();
id.ShouldBe("deadbeef");
parsed.ShouldBe(secret);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("otua")]
[InlineData("otua_")]
[InlineData("otua_onlyid")]
[InlineData("_noprefix_secret")]
[InlineData("wrong_id_secret")]
[InlineData("otua__emptyid")]
public void A_malformed_key_is_rejected_without_throwing(string? presented)
{
// These arrive from the network, so malformed input must be an ordinary auth failure.
ApiKeySecret.TryParse(presented, out _, out _).ShouldBeFalse();
}
[Fact]
public void A_wrong_secret_does_not_verify()
{
var (_, _, hash) = ApiKeySecret.Generate(Pepper);
ApiKeySecret.Verify("not-the-secret", Pepper, hash).ShouldBeFalse();
}
[Fact]
public void The_same_secret_under_a_different_pepper_does_not_verify()
{
// This is what the pepper buys: a leaked ApiKey table cannot be verified against offline.
var (presented, _, hash) = ApiKeySecret.Generate(Pepper);
ApiKeySecret.TryParse(presented, out _, out var secret).ShouldBeTrue();
ApiKeySecret.Verify(secret, "a-different-pepper", hash).ShouldBeFalse();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Hashing_under_an_absent_pepper_throws_rather_than_failing_open(string? pepper)
{
// Fail closed. An empty pepper would still produce a stable hash, so a misconfigured deployment
// would authenticate normally while offering none of the protection it believes it has.
Should.Throw<ArgumentException>(() => ApiKeySecret.ComputeHash("secret", pepper!));
}
[Fact]
public void A_non_base64_stored_hash_is_rejected_rather_than_throwing()
{
// A corrupted or hand-edited row must deny, not crash session activation for everyone.
ApiKeySecret.Verify("secret", Pepper, "!!!not base64!!!").ShouldBeFalse();
ApiKeySecret.Verify("secret", Pepper, null).ShouldBeFalse();
}
[Fact]
public void Two_generated_keys_never_collide()
{
var ids = new HashSet<string>(StringComparer.Ordinal);
var secrets = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < 200; i++)
{
var (presented, apiKeyId, _) = ApiKeySecret.Generate(Pepper);
ApiKeySecret.TryParse(presented, out _, out var secret).ShouldBeTrue();
ids.Add(apiKeyId).ShouldBeTrue($"duplicate key id '{apiKeyId}'");
secrets.Add(secret).ShouldBeTrue("duplicate secret");
}
}
}
@@ -0,0 +1,190 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// The node-local holder of the data-plane authorization inputs (#520 stage 4): generation stamping,
/// and the key/grant lookups session activation runs against.
/// </summary>
public sealed class DataPlaneAuthorityStateTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Before_any_artifact_the_node_authenticates_nothing()
{
var sut = new DataPlaneAuthorityState();
sut.Current.HasArtifact.ShouldBeFalse();
sut.Current.GenerationId.ShouldBe(0);
sut.Current.FindUsableKey("anything", Now).ShouldBeNull();
sut.Current.GrantsFor("anyone").ShouldBeEmpty();
}
[Fact]
public void Each_new_revision_advances_the_generation()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-2", [], []).GenerationId.ShouldBe(2);
}
[Fact]
public void Re_applying_the_same_revision_does_not_advance_the_generation()
{
// A node re-applies the same artifact on restart and on a redundant redeploy. Bumping the
// generation there would invalidate every cached authorization decision for no reason.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
}
[Fact]
public void The_generation_only_ever_increases_even_when_the_hash_decreases()
{
// This is the whole reason the generation is a COUNTER rather than bits taken from the revision
// hash. PermissionTrieCache treats the highest GenerationId as current, so a generation derived
// from an unordered hash would, about half the time, install a LOWER number than the deployment
// it replaces — and the cache would go on serving the PREVIOUS deployment's ACLs with nothing
// logged. Applying hashes in descending order must still produce ascending generations.
var sut = new DataPlaneAuthorityState();
var generations = new[] { "ffff", "cccc", "aaaa", "0001" }
.Select(rev => sut.Apply(null, rev, [], []).GenerationId)
.ToArray();
generations.ShouldBe([1, 2, 3, 4]);
}
[Fact]
public void A_usable_key_is_found_by_id()
{
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
sut.Current.FindUsableKey("abc", Now)!.Principal.ShouldBe("p");
}
[Fact]
public void A_key_id_matches_case_insensitively()
{
// Key ids are minted lowercase hex, but an operator copying one out of the AdminUI can upper-case
// it. A case-sensitive miss would read to them as "the key was revoked".
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("abc", "p")], []);
sut.Current.FindUsableKey("ABC", Now).ShouldNotBeNull();
}
[Fact]
public void A_revoked_key_is_not_found()
{
var revoked = Key("abc", "p");
revoked.Enabled = false;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [revoked], []);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull();
}
[Fact]
public void An_expired_key_is_not_found_but_the_same_key_is_found_before_its_expiry()
{
// The pair matters: asserting only the expired case passes just as well against a lookup that
// rejects every key with an ExpiresUtc set at all.
var expiring = Key("abc", "p");
expiring.ExpiresUtc = Now;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [expiring], []);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull("expiry is inclusive — at the instant it expires it is gone");
sut.Current.FindUsableKey("abc", Now.AddSeconds(-1)).ShouldNotBeNull();
}
[Fact]
public void Grants_are_returned_for_the_named_principal_only()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [],
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "bob", NodePermissions.WriteOperate),
]);
sut.Current.GrantsFor("alice").ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-1");
sut.Current.GrantsFor("nobody").ShouldBeEmpty();
}
[Fact]
public void Several_grants_for_one_principal_are_all_returned()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [],
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "alice", NodePermissions.WriteOperate),
]);
sut.Current.GrantsFor("alice").Count.ShouldBe(2);
}
[Fact]
public void Applying_a_new_revision_replaces_the_previous_keys_rather_than_merging_them()
{
// Merging would make revocation impossible: a key dropped from the artifact would live on in the
// node's memory forever.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("old", "p")], []);
sut.Apply(null, "rev-2", [Key("new", "p")], []);
sut.Current.FindUsableKey("old", Now).ShouldBeNull();
sut.Current.FindUsableKey("new", Now).ShouldNotBeNull();
}
[Fact]
public void A_duplicate_key_id_does_not_throw()
{
// Cannot happen from the database (the id is uniquely indexed), so this only guards a corrupt
// artifact — where refusing to build the snapshot would leave the node authenticating nothing.
var sut = new DataPlaneAuthorityState();
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], []));
sut.Current.FindUsableKey("dup", Now).ShouldNotBeNull();
}
[Fact]
public void The_cluster_id_is_carried_through()
{
// The node manager's only source of cluster identity; NodeScope.ClusterId is required and the
// evaluator fails closed when it disagrees with the session's.
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [], []);
sut.Current.ClusterId.ShouldBe("cluster-a");
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(string id, string principal, NodePermissions flags) => new()
{
NodeAclId = id,
ClusterId = "cluster-a",
Principal = principal,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = flags,
};
}
@@ -29,7 +29,7 @@ public sealed class PermissionTrieBuilderTests
NodeAclRowId = Guid.NewGuid(),
NodeAclId = $"acl-{Guid.NewGuid():N}",
ClusterId = clusterId,
LdapGroup = group,
Principal = group,
ScopeKind = scope,
ScopeId = scopeId,
PermissionFlags = flags,
@@ -15,7 +15,7 @@ public sealed class PermissionTrieTests
NodeAclRowId = Guid.NewGuid(),
NodeAclId = $"acl-{Guid.NewGuid():N}",
ClusterId = clusterId,
LdapGroup = group,
Principal = group,
ScopeKind = scope,
ScopeId = scopeId,
PermissionFlags = flags,
@@ -28,7 +28,7 @@ public sealed class TriePermissionEvaluatorTests
NodeAclRowId = Guid.NewGuid(),
NodeAclId = $"acl-{Guid.NewGuid():N}",
ClusterId = "c1",
LdapGroup = group,
Principal = group,
ScopeKind = scope,
ScopeId = scopeId,
PermissionFlags = flags,
@@ -40,7 +40,7 @@ public sealed class TriePermissionEvaluatorTests
{
SessionId = "sess",
ClusterId = clusterId,
LdapGroups = groups,
Principals = groups,
MembershipResolvedUtc = resolvedUtc ?? Now,
AuthGenerationId = authGenerationId,
MembershipVersion = 1,
@@ -13,7 +13,7 @@ public sealed class UserAuthorizationStateTests
{
SessionId = "s",
ClusterId = "c1",
LdapGroups = ["cn=ops"],
Principals = ["cn=ops"],
MembershipResolvedUtc = resolved,
AuthGenerationId = 1,
MembershipVersion = 1,
@@ -0,0 +1,272 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// End-to-end contract between the two halves of #520 stage 4: <see cref="ConfigComposer"/> writes the
/// API keys + ACL grants into the artifact, and <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/>
/// reads them back out on the node.
/// </summary>
/// <remarks>
/// These deliberately compose a REAL artifact rather than hand-writing the JSON. A hand-written fixture
/// pins the parser against my own idea of the format, not against the producer — the failure it would
/// miss is exactly the one that matters here: someone changes how the composer serialises (a naming
/// policy, a <c>JsonStringEnumConverter</c>) and every key and grant silently stops arriving at the
/// node, with no error anywhere and no test going red.
/// </remarks>
public sealed class DataPlaneAuthorityArtifactTests : ControlPlaneActorTestBase
{
private const string NodeA = "node-a:4840";
private const string NodeB = "node-b:4840";
[Fact]
public async Task A_key_survives_the_round_trip_through_a_composed_artifact()
{
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ApiKeys.Add(NewKey("abc123", "scadabridge-dcl"));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var key = parsed.Keys.ShouldHaveSingleItem();
key.ApiKeyId.ShouldBe("abc123");
key.Principal.ShouldBe("scadabridge-dcl");
key.SecretHash.ShouldBe("hash-of-abc123");
key.Enabled.ShouldBeTrue();
}
[Fact]
public async Task A_grant_survives_the_round_trip_with_its_scope_kind_and_permission_flags()
{
// The enum fields are the fragile part: ConfigComposer writes them as NUMBERS, so the parser reads
// int. If that ever diverges the grant is dropped and a key silently authenticates with no
// permissions — which looks exactly like a correctly-unprivileged key.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.NodeAcls.Add(NewAcl("acl-1", "cluster-a", "scadabridge-dcl",
NodeAclScopeKind.Cluster, NodePermissions.Read | NodePermissions.WriteOperate));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var grant = parsed.Grants.ShouldHaveSingleItem();
grant.Principal.ShouldBe("scadabridge-dcl");
grant.ScopeKind.ShouldBe(NodeAclScopeKind.Cluster);
grant.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
}
[Fact]
public async Task A_non_cluster_scope_kind_round_trips_as_itself()
{
// Falsifies "the enum happens to work because Cluster is 0". Equipment is 4, so a broken
// enum read would surface as Cluster here rather than as a dropped row.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var acl = NewAcl("acl-1", "cluster-a", "p", NodeAclScopeKind.Equipment, NodePermissions.Read);
acl.ScopeId = "EQ-1";
db.NodeAcls.Add(acl);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var grant = parsed.Grants.ShouldHaveSingleItem();
grant.ScopeKind.ShouldBe(NodeAclScopeKind.Equipment);
grant.ScopeId.ShouldBe("EQ-1");
}
[Fact]
public async Task An_expiry_round_trips_as_the_same_UTC_instant()
{
// Covers the round trip only. It does NOT cover the timezone hazard: the in-memory EF provider
// preserves DateTimeKind.Utc, so the composer writes a "Z" here and the parser's Unspecified
// branch never runs — verified by breaking that branch and watching this test still pass. SQL
// Server's datetime2 DOES materialise as Unspecified, so the branch is real in production and is
// pinned directly at the parser by
// DeploymentArtifactDataPlaneAuthorityTests.An_expiry_written_without_an_offset_is_read_as_UTC.
var expires = new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc);
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var key = NewKey("abc123", "p");
key.ExpiresUtc = expires;
db.ApiKeys.Add(key);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var parsedKey = parsed.Keys.ShouldHaveSingleItem();
parsedKey.ExpiresUtc.ShouldNotBeNull();
parsedKey.ExpiresUtc!.Value.ShouldBe(expires);
parsedKey.ExpiresUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
}
[Fact]
public async Task Grants_are_scoped_to_the_nodes_own_cluster_but_keys_are_not()
{
// Multi-cluster artifact: node-a belongs to cluster-a, so it must not receive cluster-b's grants.
// Keys carry no ClusterId — a key is a fleet-wide credential, and it is the ACL rows that decide
// what it can reach — so BOTH keys arrive.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ServerClusters.Add(NewCluster("cluster-b"));
db.ClusterNodes.Add(NewNode(NodeA, "cluster-a"));
db.ClusterNodes.Add(NewNode(NodeB, "cluster-b"));
db.ApiKeys.Add(NewKey("key-a", "principal-a"));
db.ApiKeys.Add(NewKey("key-b", "principal-b"));
db.NodeAcls.Add(NewAcl("acl-a", "cluster-a", "principal-a", NodeAclScopeKind.Cluster, NodePermissions.Read));
db.NodeAcls.Add(NewAcl("acl-b", "cluster-b", "principal-b", NodeAclScopeKind.Cluster, NodePermissions.Read));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
parsed.ClusterId.ShouldBe("cluster-a");
parsed.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-a");
parsed.Keys.Select(k => k.ApiKeyId).OrderBy(x => x).ShouldBe(["key-a", "key-b"]);
}
[Fact]
public async Task A_node_absent_from_a_multi_cluster_artifact_receives_no_grants()
{
// ResolveClusterScope reads an unlisted node as Suppress — it was not deployed to. Handing it
// another cluster's grants would be the worst possible reading of that.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ServerClusters.Add(NewCluster("cluster-b"));
db.ClusterNodes.Add(NewNode(NodeB, "cluster-b"));
db.NodeAcls.Add(NewAcl("acl-b", "cluster-b", "principal-b", NodeAclScopeKind.Cluster, NodePermissions.Read));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, "node-nowhere:4840");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public async Task A_revoked_key_still_ships_so_the_node_can_see_the_revocation()
{
// Filtering revoked keys out of the artifact would make a revocation indistinguishable from a key
// that was never deployed — and worse, would leave the node's previous snapshot as the last word
// on that id. The key ships with Enabled=false and the lookup denies it.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var key = NewKey("abc123", "p");
key.Enabled = false;
db.ApiKeys.Add(key);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
parsed.Keys.ShouldHaveSingleItem().Enabled.ShouldBeFalse();
}
[Fact]
public void An_empty_or_malformed_blob_yields_nothing()
{
DeploymentArtifact.ParseDataPlaneAuthority(ReadOnlySpan<byte>.Empty, NodeA)
.Keys.ShouldBeEmpty();
DeploymentArtifact.ParseDataPlaneAuthority("not json at all"u8, NodeA)
.Grants.ShouldBeEmpty();
}
[Fact]
public async Task Adding_a_key_changes_the_revision_hash()
{
// The node's generation stamp is driven by this hash, so a key change that did not move it would
// leave the node believing it had already applied the new authorization inputs.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
await db.SaveChangesAsync();
}
var before = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
await using (var db = f.CreateDbContext())
{
db.ApiKeys.Add(NewKey("abc123", "p"));
await db.SaveChangesAsync();
}
var after = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
after.ShouldNotBe(before);
}
private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static ServerCluster NewCluster(string id) => new()
{
ClusterId = id,
Name = id,
Enterprise = "ent",
Site = "site",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
CreatedAt = FixedTimestamp,
};
private static ClusterNode NewNode(string nodeId, string clusterId) => new()
{
NodeId = nodeId,
ClusterId = clusterId,
Host = nodeId.Split(':')[0],
ApplicationUri = $"urn:{nodeId}",
CreatedBy = "test",
CreatedAt = FixedTimestamp,
};
private static ApiKey NewKey(string apiKeyId, string principal) => new()
{
ApiKeyRowId = Guid.NewGuid(),
ApiKeyId = apiKeyId,
Name = $"key {apiKeyId}",
SecretHash = $"hash-of-{apiKeyId}",
Principal = principal,
Enabled = true,
CreatedUtc = FixedTimestamp,
};
private static NodeAcl NewAcl(
string id, string clusterId, string principal, NodeAclScopeKind scopeKind, NodePermissions flags) => new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = id,
ClusterId = clusterId,
Principal = principal,
ScopeKind = scopeKind,
PermissionFlags = flags,
};
}
@@ -1,389 +0,0 @@
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Resilience guard for <see cref="LdapOpcUaUserAuthenticator"/> (arch-review 03/S2). The OPC UA SDK
/// raises the impersonation callback synchronously under a <c>SessionManager</c>-wide event lock, so a
/// slow / hung LDAP bind inside it does not merely stall one SDK thread — it serializes EVERY session
/// activation server-wide. These tests prove the authenticator enforces a hard wall-clock bound,
/// bounded in-flight concurrency, and a directory-outage circuit at its own boundary (the SDK callback
/// being irreducibly synchronous), all fail-closed. No real LDAP server is used — a delaying fake
/// reproduces the outage shape offline.
/// </summary>
public sealed class LdapAuthResilienceTests
{
/// <summary>
/// RED repro (03/S2): a backend that takes 5 s must NOT hold the caller for 5 s — the authenticator's
/// wall-clock bound denies (fail-closed) well before then. On current code (no bound) this fails: the
/// call returns Allow after the full 5 s.
/// </summary>
[Fact]
public async Task Authenticate_slow_backend_denies_within_AuthTimeout()
{
var ldap = new DelayingFakeLdap(
TimeSpan.FromSeconds(5),
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper, new LdapOptions { AuthTimeoutMs = 200 });
var sw = Stopwatch.StartNew();
var result = await sut
.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
sw.Stop();
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error!.ShouldContain("timed out");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5));
}
/// <summary>A fast, healthy backend is unaffected by the resilience wrapping — success + roles pass
/// through untouched (03/S2).</summary>
[Fact]
public async Task Authenticate_fast_success_unaffected()
{
var ldap = new DelayingFakeLdap(
TimeSpan.Zero,
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray());
var sut = Build(ldap, mapper, new LdapOptions());
var result = await sut
.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe("Alice");
result.Roles.ShouldBe(new[] { "Designer" });
}
/// <summary>With the concurrency cap saturated (a parked in-flight bind holding the only slot), a
/// second call is denied "backend busy" within its own budget — it never waits behind the parked
/// bind indefinitely (03/S2).</summary>
[Fact]
public async Task Authenticate_saturated_concurrency_denies_busy()
{
var ldap = new GatedFakeLdap(
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.ToArray());
// MaxConcurrentAuthentications = 1 → the second call cannot get a slot; small AuthTimeoutMs so the
// "busy" denial is quick and the first call's boundary also trips fast (its core stays parked,
// holding the slot).
var sut = Build(ldap, mapper, new LdapOptions { MaxConcurrentAuthentications = 1, AuthTimeoutMs = 300 });
// First call: acquires the only slot; its core parks in the gated fake.
var first = sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
// Wait until the fake has actually been entered (slot acquired, core parked).
var spin = Stopwatch.StartNew();
while (ldap.Entered == 0 && spin.Elapsed < TimeSpan.FromSeconds(5))
await Task.Delay(10, TestContext.Current.CancellationToken);
ldap.Entered.ShouldBe(1);
// Second concurrent call: the slot is held by the parked core, so acquisition times out → busy.
var second = await sut
.AuthenticateUserNameAsync("bob", "secret", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
second.Success.ShouldBeFalse();
second.Error.ShouldNotBeNull();
second.Error!.ShouldContain("busy");
ldap.Entered.ShouldBe(1, "the second call must be denied without ever reaching the backend");
// Release the parked bind so the first call's core completes cleanly (no orphaned resources).
ldap.Release();
await first.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
}
/// <summary>After <c>OutageFailureThreshold</c> consecutive system-side failures the circuit opens and
/// the next call is fast-denied "backend unavailable" WITHOUT touching LDAP (03/S2).</summary>
[Fact]
public async Task Consecutive_system_failures_open_circuit_and_fast_deny()
{
var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure());
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper,
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
for (var i = 0; i < 3; i++)
{
var r = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
r.Success.ShouldBeFalse();
}
ldap.Calls.ShouldBe(3);
var sw = Stopwatch.StartNew();
var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
sw.Stop();
blocked.Success.ShouldBeFalse();
blocked.Error.ShouldNotBeNull();
blocked.Error!.ShouldContain("unavailable");
ldap.Calls.ShouldBe(3, "an open circuit must fast-deny without reaching the backend");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
}
/// <summary>A user-side (credential) deny between system failures resets the streak, so the circuit
/// never opens — user denies are not directory evidence (03/S2).</summary>
[Fact]
public async Task User_side_deny_resets_the_system_failure_streak()
{
var ldap = new ScriptedFakeLdap(
SystemFailure(), SystemFailure(), UserDeny(), SystemFailure(), SystemFailure());
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper,
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
// 5 scripted calls: sys, sys, user(reset), sys, sys → streak ends at 2 (< 3), circuit stays closed.
for (var i = 0; i < 5; i++)
{
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
}
// A 6th call must still REACH the backend (circuit never opened).
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
ldap.Calls.ShouldBe(6);
}
/// <summary>After the cooldown elapses the circuit half-opens: the next call probes the backend and a
/// success closes the circuit (03/S2).</summary>
[Fact]
public async Task Half_open_probe_after_cooldown_closes_on_success()
{
var ldap = new ScriptedFakeLdap(
SystemFailure(), SystemFailure(), SystemFailure(),
new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper,
new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 1, AuthTimeoutMs = 5000 });
for (var i = 0; i < 3; i++)
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
ldap.Calls.ShouldBe(3);
// Circuit open: this call is fast-denied without touching the backend.
var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
blocked.Error!.ShouldContain("unavailable");
ldap.Calls.ShouldBe(3);
// Wait past the cooldown, then a probe reaches the backend and succeeds → circuit closes.
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
var probe = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
probe.Success.ShouldBeTrue();
ldap.Calls.ShouldBe(4, "the half-open probe reaches the backend");
}
/// <summary><c>OutageFailureThreshold = 0</c> disables the circuit — it never opens, every call reaches
/// the backend (03/S2).</summary>
[Fact]
public async Task Circuit_disabled_never_opens()
{
var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure());
var mapper = new FakeMapper(g => g.ToArray());
var sut = Build(ldap, mapper,
new LdapOptions { OutageFailureThreshold = 0, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });
for (var i = 0; i < 6; i++)
await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
ldap.Calls.ShouldBe(6, "a disabled circuit never fast-denies");
}
/// <summary>
/// Outage-sim (offline-safe): the REAL <see cref="OtOpcUaLdapAuthService"/> + real shared library
/// pointed at an UNROUTABLE host (a TCP blackhole — the same outage shape as "stop the GLAuth
/// container"), driven through the REAL internal <see cref="OpcUaApplicationHost.HandleImpersonation"/>
/// block-bridge. The activation must fail bounded (well under the SDK's default ~10-20 s stall) with
/// <see cref="StatusCodes.BadIdentityTokenRejected"/> and no granted identity (03/S2).
/// </summary>
[Fact]
public async Task Activation_with_unreachable_directory_fails_within_bound()
{
var options = new LdapOptions
{
Enabled = true,
DevStubMode = false,
Server = "10.255.255.1", // RFC-blackhole: SYN goes nowhere
Port = 3893,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=zb,dc=local",
ConnectionTimeoutMs = 500,
AuthTimeoutMs = 1000,
};
var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger<OtOpcUaLdapAuthService>.Instance);
var mapper = new FakeMapper(g => g.ToArray());
var authenticator = new LdapOpcUaUserAuthenticator(
ldap, ScopeFactoryWith(mapper), Options.Create(options),
NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
var policy = new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
var args = new ImpersonateEventArgs(token, policy, new EndpointDescription());
var sw = Stopwatch.StartNew();
await Task.Run(() => OpcUaApplicationHost.HandleImpersonation(
authenticator, args, NullLogger<object>.Instance), TestContext.Current.CancellationToken)
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sw.Stop();
args.Identity.ShouldBeNull();
args.IdentityValidationError.ShouldNotBeNull();
args.IdentityValidationError.Code.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenRejected);
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3));
}
private static LdapAuthResult SystemFailure() =>
new(false, null, "u", Array.Empty<string>(), Array.Empty<string>(), "Unexpected authentication error")
{ IsSystemFailure = true };
private static LdapAuthResult UserDeny() =>
new(false, null, "u", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password")
{ IsSystemFailure = false };
private static LdapOpcUaUserAuthenticator Build(
ILdapAuthService ldap, IGroupRoleMapper<string> mapper, LdapOptions options) =>
new(ldap, ScopeFactoryWith(mapper), Options.Create(options),
NullLogger<LdapOpcUaUserAuthenticator>.Instance);
/// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
{
var services = new ServiceCollection();
services.AddScoped(_ => mapper);
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
}
/// <summary>
/// A fake <see cref="ILdapAuthService"/> that awaits a configurable delay (observing the token) before
/// returning a fixed result — the offline stand-in for a slow / hung directory. Counts invocations so
/// tests can assert the outage circuit denies WITHOUT reaching the backend.
/// </summary>
private sealed class DelayingFakeLdap : ILdapAuthService
{
private readonly TimeSpan _delay;
private readonly LdapAuthResult _then;
private int _calls;
/// <summary>Initializes the fake with a per-call delay and the result to return afterwards.</summary>
/// <param name="delay">How long each authenticate call blocks before returning.</param>
/// <param name="then">The result returned once the delay elapses.</param>
public DelayingFakeLdap(TimeSpan delay, LdapAuthResult then)
{
_delay = delay;
_then = then;
}
/// <summary>The number of times the backend was actually invoked.</summary>
public int Calls => Volatile.Read(ref _calls);
/// <summary>Awaits the configured delay, then returns the canned result.</summary>
/// <param name="username">The username (ignored).</param>
/// <param name="password">The password (ignored).</param>
/// <param name="ct">Cancellation token, observed by the delay.</param>
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
Interlocked.Increment(ref _calls);
await Task.Delay(_delay, ct).ConfigureAwait(false);
return _then;
}
}
/// <summary>
/// A fake <see cref="ILdapAuthService"/> that parks ASYNCHRONOUSLY (awaits a
/// <see cref="TaskCompletionSource"/>) until the test calls <see cref="Release"/> — used to hold an
/// in-flight bind so the concurrency cap is deterministically saturated WITHOUT blocking a thread.
/// Records how many calls have entered (past the slot acquire).
/// </summary>
private sealed class GatedFakeLdap : ILdapAuthService
{
private readonly TaskCompletionSource _park = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly LdapAuthResult _then;
private int _entered;
/// <summary>Initializes the fake with the result to return once released.</summary>
/// <param name="then">The result returned once <see cref="Release"/> is called.</param>
public GatedFakeLdap(LdapAuthResult then) => _then = then;
/// <summary>Number of authenticate calls that reached the backend body.</summary>
public int Entered => Volatile.Read(ref _entered);
/// <summary>Releases the parked bind so its awaiting call can complete.</summary>
public void Release() => _park.TrySetResult();
/// <summary>Marks entry, awaits the parking gate, then returns the canned result.</summary>
/// <param name="username">The username (ignored).</param>
/// <param name="password">The password (ignored).</param>
/// <param name="ct">Cancellation token, observed while parked.</param>
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
Interlocked.Increment(ref _entered);
await _park.Task.WaitAsync(ct).ConfigureAwait(false);
return _then;
}
}
/// <summary>
/// A fake <see cref="ILdapAuthService"/> that returns a scripted sequence of results (one per call;
/// the last entry repeats once exhausted). Counts calls so the outage-circuit tests can assert the
/// backend was — or was NOT — reached on a given call.
/// </summary>
private sealed class ScriptedFakeLdap : ILdapAuthService
{
private readonly IReadOnlyList<LdapAuthResult> _script;
private int _calls;
/// <summary>Initializes the fake with the scripted results, returned in order.</summary>
/// <param name="script">The results to return, one per call; the last repeats once exhausted.</param>
public ScriptedFakeLdap(params LdapAuthResult[] script) => _script = script;
/// <summary>The number of times the backend was invoked.</summary>
public int Calls => Volatile.Read(ref _calls);
/// <summary>Returns the next scripted result (clamped to the last entry).</summary>
/// <param name="username">The username (ignored).</param>
/// <param name="password">The password (ignored).</param>
/// <param name="ct">The cancellation token (ignored).</param>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
var idx = Interlocked.Increment(ref _calls) - 1;
return Task.FromResult(_script[Math.Min(idx, _script.Count - 1)]);
}
}
/// <summary>Test group→role mapper driven by a delegate over the supplied groups.</summary>
private sealed class FakeMapper(Func<IReadOnlyList<string>, IReadOnlyList<string>> map) : IGroupRoleMapper<string>
{
/// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
/// <param name="groups">The LDAP groups to map.</param>
/// <param name="ct">The cancellation token.</param>
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
=> Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
}
}
@@ -1,190 +0,0 @@
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R2-08 03/S2 LIVE gate — the directory-outage circuit exercised against a REAL GLAuth server
/// (the image + config PR #451 swapped in for the retired bitnami/openldap), driven through the
/// production auth path (<see cref="OtOpcUaLdapAuthService"/> + <see cref="LdapOpcUaUserAuthenticator"/>).
/// The offline <see cref="LdapAuthResilienceTests"/> prove the circuit logic with fakes + an
/// unroutable-host blackhole; this proves the same behaviour against an actual directory that is
/// paused mid-run (a real outage) and unpaused (recovery), and doubles as the first end-to-end
/// verification that the GLAuth swap binds correctly.
/// </summary>
/// <remarks>
/// Triple-gated so it never runs by accident and always restores the shared GLAuth:
/// <list type="number">
/// <item><c>GLAUTH_LIVE_HOST</c> — the GLAuth host (e.g. <c>10.100.0.35</c>); absent ⇒ skip;</item>
/// <item><c>GLAUTH_OUTAGE_START_CMD</c> — pauses the GLAuth container (a real outage); and</item>
/// <item><c>GLAUTH_OUTAGE_STOP_CMD</c> — unpauses it.</item>
/// </list>
/// A <c>try/finally</c> ALWAYS runs the stop command once the pause succeeded. Operator recipe:
/// <code>
/// ssh dohertj2@10.100.0.35 "cd ~/otopcua-harness &amp;&amp; docker compose up -d ldap"
/// export GLAUTH_LIVE_HOST=10.100.0.35
/// export GLAUTH_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-harness-ldap-1"'
/// export GLAUTH_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-harness-ldap-1"'
/// dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapGlauthOutageLive"
/// </code>
/// </remarks>
[Trait("Category", "Integration")]
[Trait("Category", "LiveLdap")]
public sealed class LdapGlauthOutageLiveTests
{
private const string HostEnvVar = "GLAUTH_LIVE_HOST";
private const string PortEnvVar = "GLAUTH_LIVE_PORT";
private const string StartCmdEnvVar = "GLAUTH_OUTAGE_START_CMD";
private const string StopCmdEnvVar = "GLAUTH_OUTAGE_STOP_CMD";
private const int OutageThreshold = 3;
private const int CooldownSeconds = 2;
private static readonly TimeSpan AuthTimeout = TimeSpan.FromMilliseconds(2_500);
/// <summary>
/// Healthy real binds succeed; pausing GLAuth makes consecutive binds fail bounded (never a
/// multi-second SDK stall) and opens the circuit into a sub-second fast-deny; unpausing +
/// cooldown lets a half-open probe close the circuit and binds succeed again.
/// </summary>
[Fact]
public async Task Real_glauth_outage_opens_circuit_then_recovers_on_unpause()
{
var host = Environment.GetEnvironmentVariable(HostEnvVar);
if (string.IsNullOrWhiteSpace(host))
Assert.Skip($"Set {HostEnvVar} (+ {StartCmdEnvVar}/{StopCmdEnvVar}) to run the live GLAuth outage cycle.");
var port = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p) ? p : 3894;
var startCmd = Environment.GetEnvironmentVariable(StartCmdEnvVar);
var stopCmd = Environment.GetEnvironmentVariable(StopCmdEnvVar);
if (string.IsNullOrWhiteSpace(startCmd) || string.IsNullOrWhiteSpace(stopCmd))
Assert.Skip($"Set both {StartCmdEnvVar} and {StopCmdEnvVar} (docker pause/unpause) to run this destructive live test.");
var ct = TestContext.Current.CancellationToken;
var options = new LdapOptions
{
Enabled = true,
DevStubMode = false,
Server = host!,
Port = port,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=zb,dc=local",
ServiceAccountDn = "cn=serviceaccount,dc=zb,dc=local",
ServiceAccountPassword = "serviceaccount123",
ConnectionTimeoutMs = 2_000,
AuthTimeoutMs = (int)AuthTimeout.TotalMilliseconds,
OutageFailureThreshold = OutageThreshold,
OutageCooldownSeconds = CooldownSeconds,
};
var authenticator = BuildAuthenticator(options);
// ── 1. Healthy directory: real binds succeed, wrong creds deny (user-side, not an outage). ──
var alice = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct);
alice.Success.ShouldBeTrue("alice should bind against the live GLAuth");
alice.Roles.ShouldNotBeEmpty("a healthy bind returns alice's mapped group roles");
var bob = await authenticator.AuthenticateUserNameAsync("bob", "bob123", ct);
bob.Success.ShouldBeTrue("bob should bind against the live GLAuth");
var wrong = await authenticator.AuthenticateUserNameAsync("alice", "wrong-password", ct);
wrong.Success.ShouldBeFalse("a wrong password is a user-side deny");
// ── 2. Outage: pause GLAuth. Consecutive binds fail BOUNDED, then the circuit fast-denies. ──
var paused = false;
try
{
await RunShellCommandAsync(startCmd!, ct);
paused = true;
for (var i = 0; i < OutageThreshold; i++)
{
var sw = Stopwatch.StartNew();
var outage = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct);
sw.Stop();
outage.Success.ShouldBeFalse($"bind #{i + 1} during the outage must fail closed");
// The whole flow is wall-clock bounded — never the SDK's default ~10-20 s stall.
sw.Elapsed.ShouldBeLessThan(AuthTimeout + TimeSpan.FromSeconds(2),
"the authenticator must bound a hung directory call, not stall on it");
}
// Circuit is now open — the next call fast-denies WITHOUT a directory round-trip (sub-second).
var blockedSw = Stopwatch.StartNew();
var blocked = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct);
blockedSw.Stop();
blocked.Success.ShouldBeFalse();
blocked.Error.ShouldNotBeNull();
blocked.Error!.ShouldContain("unavailable", Case.Insensitive,
"an open outage circuit fast-denies 'backend unavailable'");
blockedSw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1),
"an open circuit fast-denies without a directory call");
}
finally
{
if (paused)
await RunShellCommandAsync(stopCmd!, CancellationToken.None); // ALWAYS restore GLAuth
}
// ── 3. Recovery: after the cooldown the half-open probe reaches the (restored) directory. ──
await Task.Delay(TimeSpan.FromSeconds(CooldownSeconds) + TimeSpan.FromSeconds(1), ct);
var recovered = false;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (DateTime.UtcNow < deadline)
{
var probe = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct);
if (probe.Success) { recovered = true; break; }
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
}
recovered.ShouldBeTrue("the circuit must half-open and close once GLAuth is unpaused");
}
private static LdapOpcUaUserAuthenticator BuildAuthenticator(LdapOptions options)
{
var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger<OtOpcUaLdapAuthService>.Instance);
return new LdapOpcUaUserAuthenticator(
ldap, ScopeFactoryWith(new PassthroughMapper()), Options.Create(options),
NullLogger<LdapOpcUaUserAuthenticator>.Instance);
}
/// <summary>Builds an <see cref="IServiceScopeFactory"/> whose scopes resolve the supplied mapper.</summary>
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
{
var services = new ServiceCollection();
services.AddScoped(_ => mapper);
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
}
/// <summary>Runs an SSH/docker one-liner through the login shell (verbatim, as in the S7 outage gate).</summary>
private static async Task RunShellCommandAsync(string command, CancellationToken ct)
{
using var proc = new Process
{
StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
},
};
proc.Start();
await proc.WaitForExitAsync(ct);
proc.ExitCode.ShouldBe(0, $"outage command failed: {await proc.StandardError.ReadToEndAsync(ct)}");
await Task.Delay(TimeSpan.FromSeconds(1), ct); // let the pause/unpause take effect
}
/// <summary>Passes LDAP groups straight through as roles — the live gate asserts on the bind, not the mapping.</summary>
private sealed class PassthroughMapper : IGroupRoleMapper<string>
{
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
=> Task.FromResult(new GroupRoleMapping<string>(groups, Scope: null));
}
}
@@ -1,152 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Verifies <see cref="LdapOpcUaUserAuthenticator"/> translates app <see cref="ILdapAuthService"/>
/// outcomes into <c>OpcUaUserAuthResult</c>, resolves roles from the directory's <em>groups</em>
/// through the shared <see cref="IGroupRoleMapper{TRole}"/> seam (Task 1.2), unions any pre-resolved
/// roles (the DevStub Administrator grant) in, and turns LDAP backend exceptions into a denial rather
/// than letting them escape into the SDK.
/// </summary>
public sealed class LdapOpcUaUserAuthenticatorTests
{
/// <summary>On success the data-plane authenticator resolves roles via the mapper from the
/// returned Groups — not from the auth result's Roles field — and grants identity.</summary>
[Fact]
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
{
// Library-style result: groups present, Roles empty (the real path). The mapper maps the
// group "configeditor" -> "Designer" (canonical, Task 1.7).
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe("Alice");
result.Roles.ShouldBe(new[] { "Designer" });
}
/// <summary>The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
[Fact]
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
{
// DevStub-shaped result: group "dev", pre-resolved role "Administrator". Mapper maps "dev" to
// nothing, so the union is exactly {Administrator}.
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
var mapper = new FakeMapper(_ => Array.Empty<string>());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
result.Success.ShouldBeTrue();
result.Roles.ShouldBe(new[] { "Administrator" });
}
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
/// back to the pre-resolved roles, matching the login endpoint's behaviour.</summary>
[Fact]
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
{
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down"));
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
result.Success.ShouldBeTrue();
result.Roles.ShouldBe(new[] { "Administrator" });
}
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
[Fact]
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
{
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
var mapper = new FakeMapper(g => g.ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldBe("Invalid username or password");
}
/// <summary>Verifies that LDAP exceptions are converted to backend error denial results.</summary>
[Fact]
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
{
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
var mapper = new FakeMapper(g => g.ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("backend");
}
/// <summary>Verifies that authentication falls back to username when LDAP omits display name.</summary>
[Fact]
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
{
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty<string>(), null));
var mapper = new FakeMapper(g => g.ToArray());
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe("alice");
}
/// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
{
var services = new ServiceCollection();
services.AddScoped(_ => mapper);
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
}
/// <summary>Test fake implementation of LDAP authentication service.</summary>
private sealed class FakeLdap : ILdapAuthService
{
private readonly Func<string, LdapAuthResult> _handler;
/// <summary>Initializes the fake with a fixed result that ignores the username.</summary>
/// <param name="fixed_">The result to return for any authentication attempt.</param>
public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_;
/// <summary>Initializes the fake with a handler function for custom results.</summary>
/// <param name="handler">The handler to invoke with the username to produce a result.</param>
public FakeLdap(Func<string, LdapAuthResult> handler) => _handler = handler;
/// <summary>Authenticates a user asynchronously via the handler function.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password (ignored by the fake).</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(_handler(username));
}
/// <summary>Test fake group→role mapper driven by a delegate over the supplied groups.</summary>
private sealed class FakeMapper(Func<IReadOnlyList<string>, IReadOnlyList<string>> map) : IGroupRoleMapper<string>
{
/// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
/// <param name="groups">The LDAP groups to map.</param>
/// <param name="ct">The cancellation token.</param>
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
=> Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
}
}
@@ -63,19 +63,17 @@ public sealed class LdapOptionsBindingTests
}
/// <summary>
/// The five S2 resilience keys bind from the real <c>Security:Ldap</c> section (03/S2).
/// <c>ConnectionTimeoutMs</c> binds from the real <c>Security:Ldap</c> section. The other four S2
/// resilience keys were removed with <c>LdapOpcUaUserAuthenticator</c> in #520 — they bounded an
/// LDAP bind running inline on OPC UA session activation, and no remaining consumer does that.
/// </summary>
[Fact]
public void Binding_reads_resilience_keys()
public void Binding_reads_the_connection_timeout_key()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Security:Ldap:ConnectionTimeoutMs"] = "7000",
["Security:Ldap:AuthTimeoutMs"] = "12000",
["Security:Ldap:MaxConcurrentAuthentications"] = "5",
["Security:Ldap:OutageFailureThreshold"] = "4",
["Security:Ldap:OutageCooldownSeconds"] = "20",
})
.Build();
@@ -83,23 +81,13 @@ public sealed class LdapOptionsBindingTests
options.ShouldNotBeNull();
options.ConnectionTimeoutMs.ShouldBe(7000);
options.AuthTimeoutMs.ShouldBe(12000);
options.MaxConcurrentAuthentications.ShouldBe(5);
options.OutageFailureThreshold.ShouldBe(4);
options.OutageCooldownSeconds.ShouldBe(20);
}
/// <summary>The resilience keys carry the documented defaults when absent from config.</summary>
/// <summary>The connection timeout carries its documented default when absent from config.</summary>
[Fact]
public void Resilience_key_defaults_hold_when_absent()
public void Connection_timeout_default_holds_when_absent()
{
var options = new LdapOptions();
options.ConnectionTimeoutMs.ShouldBe(10000);
options.AuthTimeoutMs.ShouldBe(15000);
options.MaxConcurrentAuthentications.ShouldBe(8);
options.OutageFailureThreshold.ShouldBe(3);
options.OutageCooldownSeconds.ShouldBe(15);
new LdapOptions().ConnectionTimeoutMs.ShouldBe(10000);
}
/// <summary><c>ConnectionTimeoutMs</c> is projected onto the shared-library options so the
@@ -220,79 +220,6 @@ public sealed class LdapOptionsValidatorTests
Sut.Validate(null, options).Failed.ShouldBeTrue();
}
/// <summary>Enabled with a non-positive <c>AuthTimeoutMs</c> fails validation (03/S2).</summary>
[Fact]
public void Enabled_with_nonpositive_AuthTimeoutMs_fails()
{
var options = new LdapOptions
{
Enabled = true,
Server = "ldap",
SearchBase = "dc=x",
Port = 389,
Transport = LdapTransport.Ldaps,
AuthTimeoutMs = 0,
};
Sut.Validate(null, options).Failed.ShouldBeTrue();
}
/// <summary>Enabled with a non-positive <c>MaxConcurrentAuthentications</c> fails validation (03/S2).</summary>
[Fact]
public void Enabled_with_nonpositive_MaxConcurrentAuthentications_fails()
{
var options = new LdapOptions
{
Enabled = true,
Server = "ldap",
SearchBase = "dc=x",
Port = 389,
Transport = LdapTransport.Ldaps,
MaxConcurrentAuthentications = 0,
};
Sut.Validate(null, options).Failed.ShouldBeTrue();
}
/// <summary>A positive <c>OutageFailureThreshold</c> with a zero <c>OutageCooldownSeconds</c> fails —
/// an open circuit with no cooldown would probe on every call (03/S2).</summary>
[Fact]
public void OutageFailureThreshold_positive_with_zero_cooldown_fails()
{
var options = new LdapOptions
{
Enabled = true,
Server = "ldap",
SearchBase = "dc=x",
Port = 389,
Transport = LdapTransport.Ldaps,
OutageFailureThreshold = 3,
OutageCooldownSeconds = 0,
};
Sut.Validate(null, options).Failed.ShouldBeTrue();
}
/// <summary>A zero <c>OutageFailureThreshold</c> (circuit disabled) passes regardless of the
/// cooldown value (03/S2).</summary>
[Fact]
public void OutageFailureThreshold_zero_disables_circuit_and_passes()
{
var options = new LdapOptions
{
Enabled = true,
Server = "ldap",
SearchBase = "dc=x",
Port = 389,
Transport = LdapTransport.Ldaps,
OutageFailureThreshold = 0,
OutageCooldownSeconds = 0,
};
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
}
/// <summary>Enabled with port 0 reports the port-range failure using the shared primitive wording.</summary>
[Fact]
public void Enabled_with_zero_port_fails()
{
@@ -0,0 +1,310 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Covers the #520 stage-2 identity cutover: the API-key authenticator and the interim role
/// mapping it feeds the two live authorization gates from.
/// </summary>
public sealed class ApiKeyAuthenticationTests
{
private const string Pepper = "unit-test-pepper";
private const string Principal = "scadabridge-dcl";
// ---------- interim role mapping ----------
[Fact]
public void A_cluster_scoped_write_grant_confers_the_write_role()
{
var roles = ApiKeyInterimRoleMapper.Map(
[Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal);
roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
}
[Fact]
public void A_narrower_scoped_write_grant_confers_nothing_yet()
{
// THE load-bearing assertion of this stage. Nothing in the pipeline can honour a scope until
// stage 5, so honouring the FLAG alone would turn "WriteOperate on Equipment X" into
// server-wide write authority — the operator's narrow intent silently becoming broad. Under-
// granting is the only safe reading while scope is unenforceable.
foreach (var narrower in new[]
{
NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea,
NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace,
})
{
var roles = ApiKeyInterimRoleMapper.Map(
[Grant(narrower, NodePermissions.WriteOperate)], Principal);
roles.ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role");
}
}
[Fact]
public void A_grant_for_a_different_principal_is_ignored()
{
var other = Grant(NodeAclScopeKind.Cluster, NodePermissions.Admin);
other.Principal = "someone-else";
ApiKeyInterimRoleMapper.Map([other], Principal).ShouldBeEmpty();
}
[Fact]
public void Alarm_acknowledge_maps_to_the_alarm_role_and_read_only_maps_to_nothing()
{
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge)], Principal)
.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
// ReadOnly carries no gated permission — reads are ungated today, so it must confer no role
// rather than quietly granting one.
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.ReadOnly)], Principal)
.ShouldBeEmpty();
}
[Fact]
public void Flags_union_across_several_cluster_grants()
{
var roles = ApiKeyInterimRoleMapper.Map(
[
Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
], Principal);
roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
roles.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
}
[Fact]
public void Principal_matching_is_case_insensitive_but_not_prefix_based()
{
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal.ToUpperInvariant())
.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
// A principal that merely starts with the granted name must not inherit it.
ApiKeyInterimRoleMapper
.Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal + "-readonly")
.ShouldBeEmpty();
}
// ---------- authenticator ----------
[Fact]
public async Task A_valid_key_authenticates_as_its_principal()
{
var (presented, store) = SetUp(NodePermissions.WriteOperate, NodeAclScopeKind.Cluster);
var result = await Auth(store).AuthenticateUserNameAsync("apikey", presented, default);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe(Principal);
result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
}
[Fact]
public async Task The_wrong_username_is_denied()
{
var (presented, store) = SetUp();
var result = await Auth(store).AuthenticateUserNameAsync("alice", presented, default);
result.Success.ShouldBeFalse();
}
[Fact]
public async Task A_wrong_secret_a_missing_key_and_a_malformed_credential_are_all_denied_identically()
{
var (presented, store) = SetUp();
var auth = Auth(store);
var wrongSecret = await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", default);
var noSuchKey = await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", default);
var malformed = await auth.AuthenticateUserNameAsync("apikey", "not-a-key", default);
var empty = await auth.AuthenticateUserNameAsync("apikey", "", default);
foreach (var r in new[] { wrongSecret, noSuchKey, malformed, empty })
{
r.Success.ShouldBeFalse();
// Identical message for every failure mode: the server must not be usable as an oracle for
// which half of the credential was wrong, or whether a key id exists at all.
r.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
// ...and the valid one still works, so the above is not passing because everything is denied.
(await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeTrue();
}
[Fact]
public async Task A_store_that_throws_denies_rather_than_propagating()
{
// The interface contract forbids throwing — an exception escapes into the SDK session-activation
// path and surfaces as BadInternalError instead of a clean rejection.
var store = new ThrowingApiKeyStore();
var result = await Auth(store).AuthenticateUserNameAsync("apikey", "otua_aabbccdd_secret", default);
result.Success.ShouldBeFalse();
result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
}
[Fact]
public async Task An_unset_pepper_denies_rather_than_authenticating_everything()
{
var (presented, store) = SetUp();
var auth = new ApiKeyOpcUaUserAuthenticator(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "" }),
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
(await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeFalse();
}
// ---------- anonymous as a normal principal (D3) ----------
[Fact]
public async Task Anonymous_resolves_through_the_same_grant_path_as_a_key()
{
var (_, store) = SetUp();
store.PrincipalGrants.Add(new NodeAcl
{
NodeAclId = "ACL-anon",
ClusterId = "MAIN",
Principal = OpcUaPrincipals.Anonymous,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = NodePermissions.WriteOperate,
});
var result = await Auth(store).AuthenticateAnonymousAsync(default);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe(OpcUaPrincipals.Anonymous);
// The point of D3: anonymous can be granted WRITE, not merely tolerated as read-only.
result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
}
[Fact]
public async Task Anonymous_with_no_grants_is_admitted_with_no_roles()
{
// Today's behaviour preserved: it connects, and every role-gated operation denies.
var (_, store) = SetUp();
var result = await Auth(store).AuthenticateAnonymousAsync(default);
result.Success.ShouldBeTrue();
result.Roles.ShouldBeEmpty();
}
[Fact]
public async Task Anonymous_denies_when_grants_cannot_be_read()
{
// Deliberately NOT admit-with-no-roles. That is the pre-#520 behaviour, and defaulting to it
// exactly when we cannot read what anonymous may do stops being harmless once reads are gated.
var result = await Auth(new ThrowingApiKeyStore()).AuthenticateAnonymousAsync(default);
result.Success.ShouldBeFalse();
}
[Fact]
public async Task A_key_may_not_authenticate_as_the_reserved_anonymous_principal()
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
var key = new ApiKey
{
ApiKeyId = apiKeyId,
Name = "sneaky",
SecretHash = hash,
Principal = OpcUaPrincipals.Anonymous,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
};
var result = await Auth(new FakeApiKeyStore(key, [])).AuthenticateUserNameAsync("apikey", presented, default);
// Otherwise a credentialled client silently inherits whatever anonymous was granted.
result.Success.ShouldBeFalse();
}
[Fact]
public async Task The_default_interface_implementation_admits_anonymous_with_no_roles()
{
// NullOpcUaUserAuthenticator (driver-only nodes, pre-boot) must keep the pre-#520 behaviour
// rather than denying anonymous outright, or turning this on would black out those nodes.
// Interface-typed on purpose: a default interface implementation is only reachable through the
// interface, which is how OpcUaApplicationHost holds it.
IOpcUaUserAuthenticator sut = NullOpcUaUserAuthenticator.Instance;
var result = await sut.AuthenticateAnonymousAsync(default);
result.Success.ShouldBeTrue();
result.Roles.ShouldBeEmpty();
}
// ---------- helpers ----------
private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclId = $"ACL-{scope}-{flags}",
ClusterId = "MAIN",
Principal = Principal,
ScopeKind = scope,
ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE",
PermissionFlags = flags,
};
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
store,
Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
NullLogger<ApiKeyOpcUaUserAuthenticator>.Instance);
private static (string Presented, FakeApiKeyStore Store) SetUp(
NodePermissions flags = NodePermissions.None,
NodeAclScopeKind scope = NodeAclScopeKind.Cluster)
{
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
var key = new ApiKey
{
ApiKeyId = apiKeyId,
Name = "test key",
SecretHash = hash,
Principal = Principal,
Enabled = true,
CreatedUtc = DateTime.UtcNow,
};
var grants = flags == NodePermissions.None ? [] : new List<NodeAcl> { Grant(scope, flags) };
return (presented, new FakeApiKeyStore(key, grants));
}
private sealed class FakeApiKeyStore(ApiKey key, IReadOnlyList<NodeAcl> grants) : IApiKeyStore
{
public string KeyId => key.ApiKeyId;
/// <summary>Grants returned for a bare principal lookup (the anonymous path).</summary>
public List<NodeAcl> PrincipalGrants { get; } = [];
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
? new ApiKeyLookup(key, grants)
: null);
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<NodeAcl>>(
[.. PrincipalGrants.Where(g => string.Equals(g.Principal, principal, StringComparison.OrdinalIgnoreCase))]);
}
private sealed class ThrowingApiKeyStore : IApiKeyStore
{
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
throw new InvalidOperationException("store is down");
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
throw new InvalidOperationException("store is down");
}
}
@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// The key source for a driver-only node (#520 stage 4) — the one with no ConfigDb, which until this
/// stage denied every UserName token.
/// </summary>
public sealed class ArtifactApiKeyStoreTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
{
var sut = new ArtifactApiKeyStore(new DataPlaneAuthorityState());
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
}
[Fact]
public async Task A_deployed_key_resolves_with_its_principals_cluster_grants()
{
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "scadabridge-dcl")],
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
found.ShouldNotBeNull();
found!.Key.Principal.ShouldBe("scadabridge-dcl");
found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
}
[Fact]
public async Task Only_cluster_scoped_grants_are_returned()
{
// Matches ConfigDbApiKeyStore, which filters the same way in SQL. Narrower scopes are not yet
// enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately under-grants rather than
// promoting a scoped grant's flags to server-wide authority.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "p")],
[
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
}
[Fact]
public async Task A_revoked_key_is_denied_once_the_revoking_artifact_arrives()
{
// The whole revocation story for a driver-only node: it takes effect on the next deployment, and
// this is that deployment landing.
var state = new DataPlaneAuthorityState();
var sut = new ArtifactApiKeyStore(state);
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
var revoked = Key("abc", "p");
revoked.Enabled = false;
state.Apply("cluster-a", "rev-2", [revoked], []);
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
}
[Fact]
public async Task Anonymous_grants_resolve_without_a_key()
{
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1", [],
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)]);
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.Read);
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(string id, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclId = id,
ClusterId = "cluster-a",
Principal = principal,
ScopeKind = scope,
PermissionFlags = flags,
};
}
@@ -68,19 +68,44 @@ public sealed class OpcUaApplicationHostImpersonationTests
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
}
/// <summary>Verifies anonymous identity tokens are passed through to the SDK default handler.</summary>
/// <summary>
/// An admitted anonymous session now carries a role-bearing identity (#520).
/// </summary>
/// <remarks>
/// This test previously asserted the opposite — that anonymous fell through untouched to the SDK
/// default identity. That was the pre-#520 behaviour and is exactly what changed: anonymous is a
/// normal principal whose grants come from ACL rows, so it must arrive at the downstream gates as
/// the same <see cref="RoleCarryingUserIdentity"/> a key produces, or every gate would need an
/// anonymous-shaped branch.
/// </remarks>
[Fact]
public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default()
public void HandleImpersonation_anonymous_token_is_admitted_with_a_role_carrying_identity()
{
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
// Handler leaves anonymous tokens untouched — no identity, no validation error.
args.Identity.ShouldBeNull();
args.IdentityValidationError.ShouldBeNull();
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator");
args.Identity.ShouldBeOfType<RoleCarryingUserIdentity>();
// Still no UserName authentication — the anonymous path must not consult the credential seam.
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the UserName authenticator");
}
/// <summary>
/// With anonymous disallowed the session is rejected outright rather than admitted with no roles.
/// </summary>
[Fact]
public void HandleImpersonation_anonymous_token_is_rejected_when_disallowed()
{
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
OpcUaApplicationHost.HandleImpersonation(
authenticator, args, NullLogger<object>.Instance, allowAnonymous: false);
args.Identity.ShouldBeNull();
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
}
/// <summary>Verifies authenticator exceptions result in rejection with validation error.</summary>
@@ -88,6 +88,20 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
userName.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
/// <summary>
/// With <c>OpcUa:AllowAnonymous</c> false the Anonymous policy is not advertised, so a client is
/// refused at discovery rather than only at activation — but UserName survives, which is what
/// makes the server key-only rather than unreachable (#520).
/// </summary>
[Fact]
public void BuildUserTokenPolicies_withholds_anonymous_when_disallowed()
{
var tokens = OpcUaApplicationHost.BuildUserTokenPolicies(allowAnonymous: false).ToList();
tokens.ShouldNotContain(t => t.TokenType == UserTokenType.Anonymous);
tokens.ShouldContain(t => t.TokenType == UserTokenType.UserName);
}
/// <summary>
/// Verifies that StartAsync populates ServerConfiguration with all enabled security profiles.
/// </summary>
@@ -0,0 +1,129 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Parser-level coverage for <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/> (#520 stage 4)
/// over hand-written JSON — the shapes a composed artifact cannot produce from the in-memory EF
/// provider, plus the malformed rows the lenient-decode contract has to survive.
/// </summary>
/// <remarks>
/// The happy path is deliberately NOT tested here. It is tested in
/// <c>ControlPlane.Tests/DataPlaneAuthorityArtifactTests</c> against an artifact a real
/// <c>ConfigComposer</c> produced, because a hand-written fixture pins the parser against this file's
/// idea of the format rather than against the producer.
/// </remarks>
public sealed class DeploymentArtifactDataPlaneAuthorityTests
{
private const string NodeId = "node-a:4840";
[Fact]
public void An_expiry_written_without_an_offset_is_read_as_UTC()
{
// SQL Server's datetime2 carries no offset, so EF materialises it as DateTimeKind.Unspecified and
// the composer writes "2030-06-15T12:00:00" with no Z. Calling ToUniversalTime() on that would
// SUBTRACT the node's local offset from a value that is already UTC, moving every key's expiry by
// hours — earlier in the Americas, so keys would appear to expire before they should. The
// in-memory provider preserves Kind and cannot reproduce this, which is why it is pinned here.
var parsed = Parse($$"""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p","Enabled":true,
"CreatedUtc":"2026-01-01T00:00:00","ExpiresUtc":"2030-06-15T12:00:00"}]}
""");
var key = parsed.Keys.ShouldHaveSingleItem();
key.ExpiresUtc.ShouldBe(new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc));
key.ExpiresUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
}
[Fact]
public void An_expiry_written_with_a_Z_is_also_read_as_the_same_instant()
{
// The positive control for the test above: without it, a parser that ignored ExpiresUtc entirely
// and returned a hardcoded value would satisfy neither, but one that mangled only the Z-suffixed
// form would slip through.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p","Enabled":true,
"ExpiresUtc":"2030-06-15T12:00:00Z"}]}
""");
parsed.Keys.ShouldHaveSingleItem().ExpiresUtc
.ShouldBe(new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc));
}
[Fact]
public void A_key_row_missing_its_secret_hash_is_dropped_rather_than_admitted()
{
// A half-written row must never become a credential. Dropping it denies that key id; keeping it
// with an empty hash would hand the verifier something to compare against.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","Principal":"p","Enabled":true}]}
""");
parsed.Keys.ShouldBeEmpty();
}
[Fact]
public void A_key_row_with_no_Enabled_field_is_treated_as_revoked()
{
// The other direction would resurrect a revoked key out of a malformed artifact.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p"}]}
""");
parsed.Keys.ShouldHaveSingleItem().Enabled.ShouldBeFalse();
}
[Fact]
public void An_out_of_range_scope_kind_drops_the_grant()
{
// Casting an undefined int straight to the enum would produce a scope the trie walker can never
// match, i.e. a grant that is silently unreachable rather than visibly absent.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":99,"PermissionFlags":1}]}
""");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public void A_grant_with_an_unreadable_permission_mask_grants_nothing()
{
// Defaulting a missing bitmask to anything other than None would invent authority.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":0}]}
""");
parsed.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.None);
}
[Fact]
public void A_string_valued_scope_kind_drops_the_grant()
{
// Guards the enum-serialization drift this repo has been bitten by before: if the composer ever
// gained a JsonStringEnumConverter, grants would arrive as "Cluster" instead of 0. That must fail
// loudly in tests rather than silently drop every grant in production — the ControlPlane
// round-trip test is what goes red; this one documents the shape the parser refuses.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":"Cluster","PermissionFlags":1}]}
""");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public void An_artifact_with_neither_section_yields_empty_lists_not_a_throw()
{
var parsed = Parse("""{"DriverInstances":[]}""");
parsed.Keys.ShouldBeEmpty();
parsed.Grants.ShouldBeEmpty();
parsed.ClusterId.ShouldBeNull();
}
private static DataPlaneAuthorityProjection Parse(string json) =>
DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(json), NodeId);
}