feat(security): enforce node ACLs on write, alarms and HistoryRead (#520 stage 5)

`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
This commit is contained in:
Joseph Doherty
2026-08-01 05:00:28 -04:00
parent 9499099028
commit f692f6afe4
25 changed files with 1845 additions and 151 deletions
+83 -26
View File
@@ -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