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
@@ -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.