merge: API-key identity on the OPC UA data plane, keys+ACLs via the artifact (#520 stages 1-2, 4)
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:
@@ -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.)
|
||||
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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_<keyId>_<secret></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; }
|
||||
|
||||
+1560
File diff suppressed because it is too large
Load Diff
+104
@@ -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
|
||||
";
|
||||
}
|
||||
}
|
||||
Generated
+1618
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-10
@@ -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_<keyId>_<secret></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_<id>_<secret></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_<keyId>_<secret></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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 > <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"/> > 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-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 && 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));
|
||||
}
|
||||
}
|
||||
-152
@@ -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,
|
||||
};
|
||||
}
|
||||
+30
-5
@@ -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>
|
||||
|
||||
+129
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user