feat(security): author ACL grants from the real tree, not free text (#520 stage 7)

The last stage of #520. Enforcement landed in stages 5-6; this closes the
authoring end, where a grant could be saved, deployed and sealed green while
resolving to nothing.

Scope kind and scope target are now chosen TOGETHER from a picker over the
cluster's raw and UNS trees (AclScopePickerModal -> AclScopeTree, backed by
IAclScopeService). They were previously an unrelated dropdown and a free-text
box, so "RawDevice + a TagId" was a well-formed row. Because the trees are
rooted at the grant's own cluster and each node supplies its own kind,
NodeAclScopeCrossCluster, NodeAclScopeIdUnexpected, NodeAclScopeIdMissing and
NodeAclScopeKindRetired all become unauthorable by construction.

CheckScopeAsync mirrors DraftValidator.ValidateNodeAclScopes rule for rule and
blocks the save when a scope resolves to nothing - re-checked on submit, not
trusted from form load, because a row whose entity was deleted since is exactly
where the operator never touches the picker. The list page marks such rows
unresolved with the reason. The principal field suggests known principals and
reports how many live keys reach the one typed; reaching nobody warns rather
than blocks, since authoring ahead of minting is legitimate.

Deliberate departures from the plan line, all recorded in the design doc:
- The banners are reshaped, not removed. "Not enforced" stopped being true at
  stage 6, but enforcement is still a per-node flag the AdminUI cannot see, so
  a narrow grant really does confer nothing on a default-configured node.
- A Cluster row produces a NULL ScopeId. The deploy gate rejects one that
  carries an id, so filling in the cluster id would author undeployable rows.
- AclScopeTree is a new component rather than a third mode on RawTree, which
  already carries the /raw editing tree and the equipment reference picker.
- UNS tags are not offered: a UNS signal is a UnsTagReference, and a Tag grant
  already covers the raw node and every UNS projection of it.

Also fixed: the ACL editor had a DataAnnotationsValidator and no
ValidationSummary, so an invalid NodeAclId made Create a silent no-op.

Tests: 30 in AdminUI.Tests/Acls, falsified with six production mutations -
each turned exactly the intended tests red and no others.

Live gate PASSED on docker-dev, 11 legs; both central containers asserted on
the same image by container id. The pair that matters is legs 8 and 11: the
page and the deploy gate reject the same row with the same reason. Legs 5 and
9 are the positive controls - without them the gate passes against a page that
refuses everything. Not covered: UNS-realm scope selection (SITE-A has no UNS
areas). Rig restored to zero ACL rows.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 10:53:31 -04:00
parent d4389f57a2
commit d542ab5b7c
14 changed files with 1703 additions and 97 deletions
+59 -13
View File
@@ -228,12 +228,13 @@ 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 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
⚠️ **Node ACLs are enforced only when `Security:Acl:Enabled` is set.** 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, and read, browse, subscribe and HistoryRead are ungated for any admitted
session. With it **on**, **every** data-plane operation is decided by scoped `NodeAcl` grants — read,
browse, subscribe, write, HistoryRead and the alarm commands (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)
@@ -451,9 +452,10 @@ What is real about it:
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
specifically rather than reporting it as a missing id: the deploy gate rejects it with
`NodeAclScopeKind.FolderSegment` is the retired v2 kind with no v3 entity behind it. The stage-7 picker
does not offer it at all, so it can only arrive on a legacy row or a hand-edited one; it can never
resolve, and every end now says so specifically rather than reporting it as a missing id: the AdminUI
refuses to save it, the deploy gate rejects it with
`NodeAclScopeKindRetired`, and the trie builder drops it with a `RetiredScopeKind` diagnostic (Gitea
#533 — it used to arrive as `UnresolvedScope`, which sent an operator holding a legacy row hunting for a
typo in an id that was never the problem). One inconsistency remains: `NodePermissions.AlarmRead` has no
@@ -462,13 +464,56 @@ typo in an id that was never the problem). One inconsistency remains: `NodePermi
**String identity in this subsystem** (Gitea #533): **identifiers** — `ScopeId`, `ClusterId`,
`Principal` — compare `OrdinalIgnoreCase`, because they are opaque handles whose spelling genuinely
varies by source (a `ClusterId` arrives lower-cased through `OTOPCUA_ROLES` and verbatim through
`Cluster:Roles`; a `ScopeId` is operator free-text until the stage-7 picker lands). **Path segments** —
`Cluster:Roles`; a `ScopeId` comes from the stage-7 picker but a legacy row's may be anything).
**Path segments** —
every level of a scope path, and therefore `PermissionTrieNode.Children` — compare `Ordinal`, because
they are browse names and `Speed` / `speed` are two different nodes that can coexist under one device.
The rule is stated in full on `AclScopeRef`. It matters most where the deploy gate and the node are fed
from *different* snapshots, which is exactly the split `AclScopePathResolver` exists to bridge: case
drift there would silently drop every grant on the affected scope.
### Authoring grants (#520 stage 7)
Grants are authored at **`/clusters/{clusterId}/acls`**. Two things about the surface are worth knowing
before relying on it, because both close a way a grant could previously be saved and deployed while
authorizing nobody.
**The scope kind and the scope target are chosen together, from a tree.** "Choose scope…" opens a
picker showing the cluster's **raw device tree** (lazily expanded, exactly the `/raw` hierarchy) beside
its **UNS tree**, plus a whole-namespace row on each side. Selecting a node sets `ScopeKind` *and*
`ScopeId` in one action. Until this landed the two were an unrelated dropdown and a free-text box, so
`RawDevice` + a TagId was a well-formed row: it saved, it deployed, and it resolved to nothing.
Consequences of the trees being rooted at the grant's own cluster and of a node supplying its own kind:
- A **cross-cluster** scope is structurally unreachable — the picker's counterpart to the deploy gate's
`NodeAclScopeCrossCluster` rule, rather than a second copy of it.
- A **Cluster** row deliberately produces **no** `ScopeId`. The deploy gate rejects a Cluster grant that
carries one (`NodeAclScopeIdUnexpected`), so a picker that helpfully filled in the cluster id would
author rows that cannot be deployed.
- **UNS tags are not offered.** A UNS signal is a `UnsTagReference` to a raw tag, and a `Tag` grant
already covers the raw node *and* every UNS projection of it — so a UNS tag row would produce a grant
that reads narrower than the one it creates. Pick the tag in the raw tree.
- `FolderSegment` is not offered at all (see above).
**Rows are resolved against the live configuration, at authoring time.** `IAclScopeService.CheckScopeAsync`
mirrors `DraftValidator.ValidateNodeAclScopes` rule for rule, so the page rejects what the deployment
would reject:
- The editor **refuses to save** a grant whose scope does not resolve — re-checked on submit, not
trusted from when the form loaded, because an existing row's entity may have been deleted since and
the operator may never touch the picker.
- The **list page** resolves every row and marks a broken one `unresolved` with the reason, so a grant
that has rotted since it was authored is visible without waiting for a deploy to fail.
- The **principal** field suggests every known principal (API keys, existing grants, `anonymous`) and
reports how many *live* keys currently authenticate as the one typed. Reaching nobody is a **warning,
not a block** — authoring a grant before minting its key is legitimate; a typo is not, and the two are
indistinguishable to the server.
The checks are in `AclScopeService` rather than in the Razor pages because this repo has no bUnit:
logic only a page can reach is untestable, and "the grant authorizes nobody" is not a failure mode to
leave unpinned.
### Hierarchy (design)
ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes:
@@ -567,9 +612,10 @@ to those role strings via `GroupToRole`, e.g.:
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for
everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was
wrong for three of them.
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (with the flag off,
reads are ungated for everyone regardless; with it on, these role strings are not consulted at all —
the scoped ACL decision replaces them). Earlier revisions of this document listed all five as
"code-true"; that was wrong for three of them.
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both