From f692f6afe45560e7eedef8c34b8421a6ec452a70 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 05:00:28 -0400 Subject: [PATCH] feat(security): enforce node ACLs on write, alarms and HistoryRead (#520 stage 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide data-plane authorization at the three seams that have one, and gives HistoryRead a check it never had. Read, Browse and Subscribe stay ungated — stage 6. The ACL decision REPLACES the server-wide role check rather than chaining with it. Chained, a grant would have to confer the role as well as pass the scope check, and only Cluster-scoped grants confer a role — so every narrow grant would have stayed inert and the flag would have looked inert too. The load-bearing test is an ALLOW: a session carrying no roles at all writing successfully on the strength of its grant. A deny-only test passes against a build where the ACL is never consulted, because the role gate denies too. Found en route: the principal never reached the session. The authenticator passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity derives DisplayName from the presented token — for a key-bearing client that is the static username `apikey`, identical fleet-wide. So the trie's matching key was dropped at the identity boundary, and the audit ClientUserId on a rejected write recorded `apikey` for every consuming system. Principal and ApiKeyId are now carried explicitly. Beyond the stage as sketched: - Revocation reaches an OPEN session. Every decision re-checks the authenticating key against the current snapshot; without it a key revoked by a deployment kept its authority until the client chose to reconnect, which for a machine-to-machine consumer can be indefinitely. - UserAuthorizationState is built per CALL, not per session. The per-session shape exists so an LDAP session can re-resolve membership that changed out of band; nothing can change out of band under API keys, so the generation stamp is the whole invalidation signal and a per-call record picks it up for free. IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial. - The trie is built inside DataPlaneAuthorityState.Apply, under the same generation as the snapshot, rather than by a second wiring at the publish actor. An uninstalled trie denies everything — indistinguishable from working enforcement. Deleting the install turns 13 of the 22 new tests red. - The deploy log reports what the BUILD dropped instead of re-deriving it. The old count agreed with the builder on one of its four drop reasons; a rule with a blank ScopeId, a retired scope kind, or a path not starting at a realm vanished with the log reading zero. Both IApiKeyStore implementations widened to the full grant set. The narrowing was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and became a trap once scope was enforced. Pinned by asserting the roles are unchanged, with the two grants carrying different flags so it cannot pass by accident. Falsified by mutation: enforcement never engaging, the write seam ignoring the ACL, the realm dropped from the scope, the revocation check removed, and the trie never installed each turn the intended tests red and no others. Full solution suite green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- CLAUDE.md | 32 +- docs/Configuration.md | 21 +- ...026-07-31-apikey-acl-enforcement-design.md | 45 +- docs/security.md | 109 +++-- .../Authorization/AclOptions.cs | 45 ++ .../Authorization/DataPlaneAuthority.cs | 85 +++- .../Components/Pages/Clusters/AclEdit.razor | 31 +- .../Pages/Clusters/ClusterAcls.razor | 32 +- .../OpcUa/ApiKeyOpcUaUserAuthenticator.cs | 5 +- .../OpcUa/ArtifactApiKeyStore.cs | 48 +-- .../OpcUa/ConfigDbApiKeyStore.cs | 22 +- .../OpcUa/OtOpcUaServerHostedService.cs | 28 +- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 27 ++ .../OpcUaApplicationHost.cs | 8 +- .../OtOpcUaNodeManager.cs | 184 +++++++- .../OtOpcUaSdkServer.cs | 17 + .../Security/DataPlaneAuthorizer.cs | 197 +++++++++ .../Security/IOpcUaUserAuthenticator.cs | 36 +- .../Security/RoleCarryingUserIdentity.cs | 36 ++ .../OpcUa/OpcUaPublishActor.cs | 33 +- .../DataPlaneAuthorityStateTests.cs | 96 +++++ .../OpcUa/ArtifactApiKeyStoreTests.cs | 46 +- .../OpcUa/ConfigDbApiKeyStoreTests.cs | 22 +- .../DataPlaneAuthorizerTests.cs | 393 +++++++++++++++++ .../NodeManagerAclEnforcementTests.cs | 398 ++++++++++++++++++ 25 files changed, 1845 insertions(+), 151 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclOptions.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DataPlaneAuthorizerTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 9947f054..fe799856 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -610,12 +610,32 @@ the data-plane authenticator when you see LDAP wiring in `Program.cs` — it is 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**. +- ⚠️ **Node ACLs are enforced only behind `Security:Acl:Enabled` (default `false`), and only for three + operations.** With the flag **off**, a key's grants collapse into the two 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. With the flag **on** (stage 5, `DataPlaneAuthorizer`), scoped grants decide **write, + alarm commands and HistoryRead** — the last of which had no authorization check of any kind before. + **Read, Browse and CreateMonitoredItems stay ungated in BOTH modes**, for any admitted session + including Anonymous; that is stage 6. + - ⚠️ **The ACL decision REPLACES the role check — the two are never chained.** Chained, a grant would + have to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped + grants confer a role, so every narrow grant would stay inert and the flag would appear to do + nothing. The load-bearing test is therefore an **allow** — a session carrying *no roles* writing + successfully on the strength of its grant. A deny-only test passes against a build where the ACL is + never consulted, because the role gate denies too. + - **The trie is built inside `DataPlaneAuthorityState.Apply`**, not by a second wiring at the publish + actor, and the state owns its `PermissionTrieCache` rather than being handed one. An uninstalled + trie denies everything, which is indistinguishable from working enforcement until someone checks + that an ALLOW is possible at all. + - **A deploy reaches sessions that are already open**, in both directions: each decision stamps the + *current* generation rather than the one the session activated under, and re-checks the + authenticating key — so revoking a key or a grant stops an open session immediately instead of at + the client's next reconnect. On a driver-only node the artifact is the only revocation channel. + - `UserAuthorizationState` is built **per call, not per session** — its freshness/staleness predicates + are LDAP-shaped and cannot fire here. Caching belongs at stage 6's publish-path seams, keyed + per-`(session, node)`. - **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 diff --git a/docs/Configuration.md b/docs/Configuration.md index eff4ab94..f50f3cc1 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -63,14 +63,31 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only ### `Security` -- **Purpose:** Admin-UI and OPC UA authentication. Three subsections, each its own Options class: +- **Purpose:** Admin-UI and OPC UA authentication + data-plane authorization. Five subsections, each its own Options class: | Subsection | Options class (`SectionName`) | Purpose | |---|---|---| -| `Security:Ldap` | `LdapOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs` | LDAP bind for Admin cookie login + OPC UA UserName tokens. Bound by `AddValidatedOptions` in `Program.cs`. | +| `Security:Ldap` | `LdapOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs` | LDAP bind for **Admin cookie login only**. The OPC UA data plane moved to API keys in #520. Bound by `AddValidatedOptions` in `Program.cs`. | +| `Security:ApiKeys` | `ApiKeyOptions` — `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs` | OPC UA data-plane identity: the static `Username` every key-bearing client presents, and the server `Pepper` the stored key hashes are computed under. Bound unconditionally — a driver node needs the pepper to verify keys, an admin node to mint them. | +| `Security:Acl` | `AclOptions` — `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclOptions.cs` | Whether node ACL grants decide data-plane authorization. See below. | | `Security:Jwt` | `JwtOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs` | Signing config for the JWT minted at `/auth/token` for **external** consumers (OPC UA clients / automation). | | `Security:Cookie` | `OtOpcUaCookieOptions` — `src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs` | The Admin-UI auth cookie (`AddOtOpcUaAuth` copies these onto `CookieAuthenticationOptions`). | +**`Security:ApiKeys`** key fields (`ApiKeyOptions`): + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `Username` | string | `apikey` | The fixed username a key-bearing client sends in its OPC UA UserName token. Carries no information — the key identifies the principal — but is checked rather than ignored so a typo is an explicit denial. | +| `Pepper` | string | `""` | Server-side pepper for the stored key hashes. **Supply via `Security__ApiKeys__Pepper` or `${secret:}` — never commit it.** No default on purpose: an empty pepper still hashes "successfully", so `ApiKeySecret.ComputeHash` throws on one rather than producing a deployment with none of the protection it believes it has. Rotating it invalidates every existing key. | + +**`Security:Acl`** key fields (`AclOptions`): + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `Enabled` | bool | `false` | `true` makes scoped `NodeAcl` grants decide **write**, **alarm-command** and **HistoryRead** authorization, *replacing* the server-wide `WriteOperate` / `AlarmAck` role checks. `false` keeps those role checks and leaves HistoryRead ungated — byte-for-byte the pre-stage-5 behaviour. Read, Browse and Subscribe are ungated either way. | + +> **Flipping `Security:Acl:Enabled` is a config change, not a redeploy** — both paths are wired in both modes (the node builds its permission trie on every deploy regardless), the same dark-switch discipline as [`MeshTransport`](#meshtransport), [`ConfigSource`](#configsource) and [`Telemetry`](#telemetry). It defaults off because the first deployment carrying the code would otherwise start denying every client that has no key, **including ScadaBridge's Data Connection Layer, which consumes this address space from another repo.** Enforcement fails closed on every unresolvable case, so a wiring or config mistake presents as *everything denied* — check the `OpcUaPublish: data-plane authority generation N installed` line for a non-zero grant count before debugging an individual rule. See [`security.md` § Node ACL enforcement](security.md#node-acl-enforcement-securityaclenabled). + **`Security:Ldap` — see [`security.md`](security.md) for the full field-by-field reference and bind-flow.** The checked-in role overlays set only `DevStubMode` and `Transport`; the remaining `LdapOptions` fields (`Enabled`, `Server`, `Port`, `AllowInsecure`, `SearchBase`, `ServiceAccountDn`, `ServiceAccountPassword`, `GroupAttribute`, `DisplayNameAttribute`, `UserNameAttribute`, `GroupToRole`) are covered there. **`Security:Jwt`** key fields (`JwtOptions`): diff --git a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md index d883cb95..5eaa6665 100644 --- a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md +++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md @@ -195,11 +195,46 @@ Each stage is one commit, builds clean, and leaves the tree shippable. 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). +5. **Enforce write + alarms + HistoryRead.** ✅ **Done.** `AclOptions` (`Security:Acl:Enabled`, default + false); `DataPlaneAuthorizer` projects a NodeId + realm onto a `NodeScope` and evaluates it through the + existing `TriePermissionEvaluator`; the trie is built and installed inside `DataPlaneAuthorityState.Apply`; + both `IApiKeyStore` implementations widened to the full grant set. + + Five things went differently from this line, all deliberate: + + - **The role check is REPLACED, not supplemented.** Running both in series would require a grant to + confer the server-wide role as well as pass the scope check — and only `Cluster`-scoped grants + confer a role — so every narrow grant would have stayed inert and the flag would have looked + inert too. Narrow grants working is the point of the stage. Pinned by a test that writes + successfully from a session carrying **no roles at all**; the deny-side test alone passes against + a build where the ACL is never consulted, because the role gate denies too. + - **`UserAuthorizationState` is built per CALL, not per session.** The per-session shape was + LDAP-inherited: it exists so a long-lived session can re-resolve group membership that changed out + of band. Nothing can change out of band under API keys, so the generation stamp is the whole + invalidation signal and a per-call record picks it up for free — where a cached one would need + explicit invalidation to do the same job. `IsStale`/`NeedsRefresh` therefore cannot fire here and + are documented as vestigial rather than left looking load-bearing. Stage 6 is where caching earns + its keep, and it wants a per-`(session, node)` decision cache, not this type. + - **Revocation now reaches an OPEN session**, which the sketch did not ask for. Every decision + re-checks the authenticating key against the current snapshot. Without it a key revoked by a + deployment kept its open session's authority until the client chose to reconnect — indefinitely, + for a machine-to-machine consumer — which would have made the documented + "revocation requires a deploy" sharp edge considerably sharper than documented. + - **The trie is built inside `DataPlaneAuthorityState.Apply`**, not by a separate install step at the + publish actor. As two wirings, the trie install is the one that silently does not run, and an + absent trie denies everything — indistinguishable from working enforcement until someone checks + that an ALLOW is possible. Falsified: deleting the install turns 13 of the 22 stage-5 tests red. + - **The deploy log now reports what the BUILD dropped** instead of re-deriving it. The previous + `unresolvedScopes` count agreed with the builder on one of its four drop reasons; a rule with a + blank `ScopeId`, a retired scope kind, or a scope path not starting at a realm vanished with the + log reading zero. + + Also found and fixed en route: **the principal never reached the session.** The authenticator passed + it as `OpcUaUserAuthResult.DisplayName`, but `RoleCarryingUserIdentity` derives `DisplayName` from + the presented *token* — which for every key-bearing client is the static username `apikey`. So the + trie's matching key was being dropped at the identity boundary, and the audit `ClientUserId` on a + rejected write recorded `apikey` for every consuming system. `Principal` and `ApiKeyId` are now + carried explicitly. 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. diff --git a/docs/security.md b/docs/security.md index 8f308e01..f69b1b2e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -228,10 +228,12 @@ Two consequences worth knowing: 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 +⚠️ **Node ACLs are enforced only when `Security:Acl:Enabled` is set, and only for three operations.** +With the flag **off — the default** — a key's grants are collapsed into the two server-wide role gates +and **only `Cluster`-scoped grants count**, so a narrower grant confers nothing rather than silently +widening to server-wide authority. With it **on**, writes, alarm commands and HistoryRead are decided +by scoped `NodeAcl` grants; **reads, browses and subscriptions stay ungated in either mode** (stage 6). +See [Node ACL enforcement](#node-acl-enforcement-securityaclenabled) and `docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. ### LDAP resilience — mostly retired with the data-plane authenticator (#520) @@ -263,14 +265,15 @@ the one request making it. One key remains: ## Data-Plane Authorization -> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the -> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The -> evaluator is built and unit-tested but has **zero production call sites**, and -> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything -> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is -> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie). +> ⚠️ **Read this first (updated 2026-08-01, #520 stage 5).** Whether the `NodeAcl` / `PermissionTrie` +> subsystem runs depends on one flag. With **`Security:Acl:Enabled` false — the default** — it does not: +> the only authorization in the server is the coarse role checks in +> [What is enforced with the flag off](#what-is-enforced-with-the-flag-off). With it **true**, those role +> checks are *replaced* by scoped ACL decisions for write, alarm commands and HistoryRead — see +> [Node ACL enforcement](#node-acl-enforcement-securityaclenabled). **Read, Browse and +> CreateMonitoredItems are ungated in both modes.** -### What is enforced today +### What is enforced with the flag off There are exactly **three** authorization checks in the whole OPC UA server layer. All three are coarse, **server-wide role string checks** with no per-node component. @@ -311,11 +314,56 @@ The gate *is* realm-aware in one narrow sense: it is attached per-node during ma the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself takes no realm and no node. -### Designed but not wired: `NodeAcl` + `PermissionTrie` +### Node ACL enforcement (`Security:Acl:Enabled`) -Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that -**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design -is sound and the remaining work is wiring plus one genuine design gap — not because it runs. +| Key | Default | Effect | +|---|---|---| +| `Security:Acl:Enabled` | `false` | `true` makes scoped `NodeAcl` grants decide write, alarm-command and HistoryRead authorization | + +**It is a config change, not a redeploy.** Both paths are wired in both modes — the node always builds +its permission trie on every deploy and always constructs the gate; the flag only chooses which decision +the seams consult. Same dark-switch discipline as `MeshTransport`, `ConfigSource` and `Telemetry`. + +#### What changes when it is on + +| Operation | Off | On | +|---|---|---| +| Write (Value) | role `WriteOperate` | `NodePermissions.WriteOperate` on that node's scope | +| Alarm Acknowledge / AddComment / Enable / Disable | role `AlarmAck` | `AlarmAcknowledge` on the condition's scope | +| Alarm Confirm | role `AlarmAck` | `AlarmConfirm` | +| Alarm OneShotShelve / TimedShelve / Unshelve | role `AlarmAck` | `AlarmShelve` | +| HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** | +| Read, Browse, CreateMonitoredItems | nothing | **still nothing** — stage 6 | + +⚠️ **The ACL decision REPLACES the role check; the two are not chained.** Chaining them would require a +grant to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped grants +confer a role (`ApiKeyInterimRoleMapper`) — so every narrow grant would remain inert and the flag would +look like it did nothing. Narrow grants working is the whole point of the stage. + +Two behaviours worth knowing before enabling it: + +- **A deploy takes effect on sessions that are already open.** The gate stamps each decision with the + current authority generation rather than the one the session activated under, so an ACL edit lands + without bouncing clients — in both directions, widening and narrowing. +- **Revocation also reaches an open session.** Every decision re-checks the authenticating key against + the current snapshot, so a key disabled or expired by a later deployment stops working immediately + rather than at the client's next reconnect. On a driver-only node the artifact is the only revocation + channel there is, so without this a revoked credential could keep its authority indefinitely. + +#### Everything denies when something is missing + +No identity, no principal on the identity, **no cluster identity on the node**, a NodeId with no string +identifier, or a trie that was never installed — each returns `BadUserAccessDenied`. The client is told +nothing beyond that; the reason is logged server-side (`AclGate: denied …`) so the server cannot be used +as an oracle for which nodes exist or which grants a principal holds. + +The consequence to plan for: a wiring or configuration mistake presents as **everything denied**, not +everything allowed. Confirm the trie exists at all before debugging a specific rule — the +`OpcUaPublish: data-plane authority generation N installed` line reports the grant count, and a separate +warning names every rule the build **dropped** (a dangling `ScopeId`, a blank one, a retired scope kind, +or a scope path that does not start at a realm). A dropped rule can never match anything. + +### The `NodeAcl` + `PermissionTrie` subsystem What is real about it: @@ -323,13 +371,16 @@ 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 **does** deserialize them as of #520 stage 4 — +- The node side **does** deserialize them (#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. + `IDataPlaneAuthoritySource` on every apply. The node manager holds that source, so it has a cluster + identity. +- The same `Apply` call **builds and installs the permission trie** (#520 stage 5), under the same + generation as the snapshot. Deliberately one call rather than two wirings: an uninstalled trie denies + everything, which is indistinguishable from working enforcement until someone checks that an ALLOW is + possible at all. +- `DataPlaneAuthorizer` **evaluates** them at the three seams above, when the flag is on. 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 @@ -352,12 +403,18 @@ until the tracking issue below is closed. can never reach. A scope resolves to *several* paths when one entity is reachable at several NodeIds (a raw tag projected into N equipments has one raw path plus N UNS paths), because a grant that covered only some of them would be bypassable through the one it missed. -4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but - 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. +4. ~~**No session lifecycle exists.**~~ **Closed by #520 stage 5, though not as the blocker imagined.** + The generation exists and advances (stage 4), and `DataPlaneAuthorizer` now builds a + `UserAuthorizationState` stamped with it — but **per authorization call, not per session**. The + per-session design was LDAP-shaped: it assumed a directory could change a session's group membership + out of band, so a long-lived session had to re-resolve on a timer and serve cached memberships in + between. Under API keys nothing can change out of band — the principal comes from the key, and keys + and grants both ride the deployment artifact — so the generation stamp is the entire invalidation + signal, and rebuilding a small record per call picks it up for free while a cached one would have to + be explicitly invalidated. The consequence is that `UserAuthorizationState.IsStale` and `NeedsRefresh` + can never fire on this path; they are vestigial here rather than load-bearing. Caching becomes + necessary at stage 6's read/browse/subscribe seams, which are on the publish path — and it will want + a per-`(session, node)` decision cache, not this type. `NodeAclScopeKind.FolderSegment` is the retired v2 kind with no v3 entity behind it. It is still offered in the authoring dropdown (labelled retired), but it can never resolve, and both ends now say so diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclOptions.cs new file mode 100644 index 00000000..1efe641c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AclOptions.cs @@ -0,0 +1,45 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// +/// Security:Acl — the switch that turns per-node ACL evaluation into the authorization +/// decision on the OPC UA data plane (#520, stage 5). +/// +/// +/// +/// Default OFF, and that is the whole point. This is the dark-switch discipline +/// MeshTransport, ConfigSource and Telemetry all shipped under: both sides +/// are wired in both modes, so flipping it is a config change rather than a redeploy. Without it, +/// the first deployment carrying this code would start denying every client that has no key — +/// including ScadaBridge's Data Connection Layer, which consumes this address space from +/// another repo and has never presented one. +/// +/// +/// What actually swaps. With it off, the two long-standing server-wide +/// role gates (WriteOperate on Value writes, AlarmAck on the Part 9 methods) remain +/// the only authorization in the server and behave exactly as they always have; HistoryRead stays +/// ungated. With it on, those role checks are replaced — not supplemented — by a scoped ACL +/// decision, and HistoryRead gains one. +/// +/// +/// ⚠️ Replaced, not supplemented, is load-bearing. Running both in series would mean a grant +/// had to confer the server-wide role as well as pass the scope check, and only +/// Cluster-scoped grants confer a role (see ApiKeyInterimRoleMapper) — so every +/// narrow grant would still be inert and the flag would appear to do nothing. Narrow grants +/// working is the entire reason this stage exists. +/// +/// +/// Read, Browse and CreateMonitoredItems are still ungated in either mode — that is stage 6, and +/// the AdminUI banners stay up until it lands. +/// +/// +public sealed class AclOptions +{ + /// The configuration section name. + public const string SectionName = "Security:Acl"; + + /// + /// When true, node ACL grants decide write, alarm-command and HistoryRead authorization. Default + /// false, which preserves the pre-stage-5 role gates byte-for-byte. + /// + public bool Enabled { get; set; } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs index 8ec90efe..234ec915 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -32,6 +32,7 @@ public sealed class DataPlaneAuthoritySnapshot IReadOnlyList keys, IReadOnlyList grants, IReadOnlyDictionary> scopePaths, + IReadOnlyList droppedGrants, IReadOnlyDictionary keysById, IReadOnlyDictionary> grantsByPrincipal) { @@ -41,6 +42,7 @@ public sealed class DataPlaneAuthoritySnapshot Keys = keys; Grants = grants; ScopePaths = scopePaths; + DroppedGrants = droppedGrants; _keysById = keysById; _grantsByPrincipal = grantsByPrincipal; } @@ -76,6 +78,18 @@ public sealed class DataPlaneAuthoritySnapshot /// public IReadOnlyDictionary> ScopePaths { get; } + /// + /// Grants the trie build discarded, with the reason. Each one is an authored rule that will never + /// match anything, so this is the only signal an operator gets that a rule they wrote is inert. + /// + /// + /// Reported by the build itself rather than re-derived by the caller. An earlier version of the + /// install log counted unresolvable scopes independently, which agreed with the builder only for + /// the UnresolvedScope reason — it silently missed rows dropped for a missing + /// ScopeId, a retired scope kind, or a scope path that does not start at a realm. + /// + public IReadOnlyList DroppedGrants { get; } + /// True once a real artifact has been applied. public bool HasArtifact => GenerationId > 0; @@ -112,6 +126,7 @@ public sealed class DataPlaneAuthoritySnapshot /// The API keys carried by the artifact. /// The ACL grants for this node's cluster. /// Where each grant's scope sits in the address space. + /// Grants the trie build discarded, or empty when no trie was built. /// The built snapshot. public static DataPlaneAuthoritySnapshot Create( string? clusterId, @@ -119,7 +134,8 @@ public sealed class DataPlaneAuthoritySnapshot long generationId, IReadOnlyList keys, IReadOnlyList grants, - IReadOnlyDictionary> scopePaths) + IReadOnlyDictionary> scopePaths, + IReadOnlyList? droppedGrants = null) { ArgumentNullException.ThrowIfNull(revisionHash); ArgumentNullException.ThrowIfNull(keys); @@ -154,7 +170,7 @@ public sealed class DataPlaneAuthoritySnapshot StringComparer.OrdinalIgnoreCase); return new DataPlaneAuthoritySnapshot( - clusterId, revisionHash, generationId, keys, grants, scopePaths, byId, frozen); + clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen); } } @@ -207,11 +223,35 @@ public interface IDataPlaneAuthoritySink /// Process-singleton holder of . Registered on every /// driver-role node; the artifact-apply path writes it and OPC UA session activation reads it. /// +/// +/// +/// It owns the rather than being handed one (#520 stage 5). +/// The trie is built from the same grants and the same generation as the snapshot, inside the same +/// call, so the two cannot describe different deployments. Injecting the cache +/// would have made "install the snapshot" and "install the trie" two wirings, and this repo has +/// been bitten repeatedly by the second one silently not running — a trie that was never installed +/// denies everything, which reads as "ACLs are working" until someone checks whether an ALLOW is +/// possible at all. +/// +/// public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPlaneAuthoritySink { + /// + /// Generations of the trie retained per cluster. One is enough for correctness now — every + /// authorization call stamps itself with the current generation — but an in-flight request that + /// read the snapshot just before a deploy landed still resolves against the generation it saw. + /// + private const int RetainedTrieGenerations = 3; + private readonly Lock _gate = new(); private DataPlaneAuthoritySnapshot _current = DataPlaneAuthoritySnapshot.Empty; + /// + /// The permission tries built from the applied artifacts, keyed (cluster, generation). Read + /// by on the authorization path. + /// + public PermissionTrieCache Tries { get; } = new(); + /// public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current); @@ -244,10 +284,49 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl ? previous.GenerationId : previous.GenerationId + 1; + // Build and install the trie BEFORE publishing the snapshot. An authorization call that reads + // the new snapshot must find its trie already in the cache; publishing first opens a window in + // which a session stamps itself with a generation the cache has not got, and the evaluator + // fails closed on exactly that condition. The window would be microseconds and only ever + // reachable during a deploy — precisely the kind of defect that never reproduces. + var dropped = BuildAndInstallTrie(clusterId, generation, grants, scopePaths); + var next = DataPlaneAuthoritySnapshot.Create( - clusterId, revisionHash, generation, keys, grants, scopePaths); + clusterId, revisionHash, generation, keys, grants, scopePaths, dropped); Volatile.Write(ref _current, next); return next; } } + + /// + /// Builds the permission trie for a generation and installs it in . + /// + /// + /// A node with no cluster identity gets no trie. The trie is keyed by cluster and the evaluator + /// matches the session's cluster against the node's, so there is no honest key to file it under; + /// inventing a sentinel would silently apply one cluster's grants to a node whose cluster is + /// unknown. With enforcement enabled such a node denies everything, which is the fail-closed + /// direction and is surfaced by the caller's install log. + /// + /// The node's cluster, or null/blank when it has none. + /// The generation to file the trie under. + /// The ACL grants to plant. + /// Where each grant's scope sits in the address space. + /// The grants the build discarded. + private IReadOnlyList BuildAndInstallTrie( + string? clusterId, + long generationId, + IReadOnlyList grants, + IReadOnlyDictionary> scopePaths) + { + if (string.IsNullOrWhiteSpace(clusterId)) return []; + + var diagnostics = new List(); + var trie = PermissionTrieBuilder.Build( + clusterId, generationId, grants, scopePaths, diagnostics.Add); + + Tries.Install(trie); + Tries.Prune(clusterId, RetainedTrieGenerations); + return diagnostics; + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index 8eb24569..fe57d457 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -29,21 +29,28 @@ else if (!IsNew && _existing is null) else {