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
@@ -108,7 +108,10 @@ mismatch).
`Descend` like any other kind, so given a path it *would* have matched and been reported under
whatever level it happened to land on. It was unlabellable, not unplaceable.
- `NodePermissions.AlarmRead` has no `OpcUaOperation` mapping — grantable but unreachable.
- `NodeAcl.ScopeId` has no FK and no existence check, so scope ids dangle silently.
- ~~`NodeAcl.ScopeId` has no FK and no existence check, so scope ids dangle silently.~~ **Closed** —
stage 3 gave it a deploy-time existence check, and stage 7 an authoring-time one plus a picker that
cannot produce a dangling id. There is still no FK: `ScopeId` names a row in one of eight tables
chosen by `ScopeKind`, which is not a foreign key any schema can express.
- ~~`ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all~~ — **stale**; stage 1 added
the reference. The line read as an open item long after it was closed.
- `NodePermissions.HistoryUpdate` is deliberately in no bundle until HistoryUpdate exists. Leave it.
@@ -169,8 +172,8 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
Also closed here: `NodeAcl` rows had **no deploy-time validation of any kind** and were not even
reachable from `DraftValidator` (`DraftSnapshot` carried no `NodeAcls` collection). They are now
gated for dangling ids, cross-cluster scopes, blank/contradictory `ScopeId`, the retired
`FolderSegment` kind, and undefined ordinals. That gate is what substitutes for the ACL scope picker
until stage 7 — `ScopeId` is still a free-text box.
`FolderSegment` kind, and undefined ordinals. That gate substituted for the ACL scope picker until
stage 7 shipped one; it remains the backstop for rows edited outside the AdminUI.
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
@@ -271,8 +274,37 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
the moment anything injects a test clock — at which point the evaluator denies *everything* with a
verdict indistinguishable from "no grant matched". Stage 5's claim that these predicates "can never
fire" was accidentally true, not true by construction; they are now disabled explicitly.
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.
7. **AdminUI.** ✅ **Done.** Key management (create/revoke, show-once) landed early, in stage 4's
predecessor. This stage added the **scope picker** (`AclScopePickerModal` over the cluster's raw and
UNS trees, backed by `IAclScopeService`), authoring-time **scope and principal resolution**, and
reshaped the two banners.
Four things went differently from this line, all deliberate:
- **The banners were reshaped, not removed.** "Remove the two warning banners" assumed the only thing
they said was *not enforced*, which stage 6 falsified. What remains true is that enforcement is a
**per-node** flag the AdminUI cannot see, so an `Equipment`-scoped grant still confers nothing on a
default-configured node — deleting the banner would have replaced one wrong impression with
another. They are now `alert-info` describing both modes, flag-on first.
- **The picker sets the kind AND the id, and there is no longer a hand-edit path.** The plan asked
for "a `RawTree` picker for `ScopeId`", which would have left the kind dropdown beside it and kept
`RawDevice` + a TagId authorable. Selecting a tree node supplies both, which makes
`NodeAclScopeCrossCluster`, `NodeAclScopeIdUnexpected`, `NodeAclScopeIdMissing` and
`NodeAclScopeKindRetired` unauthorable by construction rather than merely discouraged.
- **It is a new component, not a third mode on `RawTree`.** `RawTree` already carries the `/raw`
editing tree and the equipment reference picker. A third mode would put the scope picker's bugs on
the device-authoring page, and with no bUnit in this repo the only thing that would catch that is
someone noticing. `AclScopeTree` renders a unified `AclScopeNode` instead, which is also what lets
ONE component serve both hierarchies — `RawTree` covers only the raw side.
- **Saving is blocked on an unresolved scope, and the list page marks the rot.** Not asked for; it is
what closes `ScopeId` dangling silently (the open item at the top of this doc) rather than moving
the discovery from deploy time to slightly-earlier deploy time. The check re-runs on submit rather
than trusting the verdict from when the form loaded, because a row loaded unchanged is exactly the
case where the operator never touches the picker.
Also fixed en route: the ACL editor had a `DataAnnotationsValidator` and **no `ValidationSummary`**,
so an invalid `NodeAclId` made the Create button a silent no-op — the [[project_adminui_no_bunit_live_verify]]
failure mode, present in this page since it was written.
8. **Rollout.** Docs, the ScadaBridge key hand-off, and flipping `Security:Acl:Enabled` on docker-dev
as the live gate.
@@ -350,6 +382,41 @@ Rig left as found: key, ACL row, the second device and its tag all deleted and r
returned to `Security:Acl:Enabled` unset, confirmed by the absence of the startup banner and an
anonymous read succeeding again.
## Stage 7 live gate — PASSED (docker-dev, 2026-08-01)
Driven through the AdminUI at `:9200` against cluster **SITE-A**. Both central containers were asserted
to be running the same freshly-built image *by container id*, not from one `docker compose build` — the
stale-central-2 trap from the stage-6 gate.
| # | Leg | Result |
|---|---|---|
| 1 | Picker lazily expands the real raw tree | ✅ cluster → `sa-modbus2` → `plc` → `SiteAOnly`, one level per expand |
| 2 | Each level offers its own scope kind | ✅ `CLUSTER` / `RAWDRIVER` / `RAWDEVICE` / `TAG` chips |
| 3 | Selecting a node sets kind **and** id | ✅ → `RAWDEVICE` + `SITEA-dev`, "✓ Device 'plc' in cluster 'SITE-A'" |
| 4 | Principal reaching nobody warns | ✅ `not-a-real-principal` → ⚠ "No active API key uses this principal" |
| 5 | …and the positive control | ✅ `anonymous` → ✓ "The reserved anonymous principal" |
| 6 | Grant saves and lists with its resolved target | ✅ |
| 7 | A rotted row is marked on the list | ✅ id broken by SQL → `UNRESOLVED` chip + reason |
| 8 | Saving a rotted row is **blocked** | ✅ "Scope: No device in this configuration has the id …" |
| 9 | Re-picking a valid scope unblocks it | ✅ `NAMESPACE raw` → saved |
| 10 | The authored grant **deploys** | ✅ `POST /api/deployments` → `Sealed` |
| 11 | The deploy gate rejects what the page rejects | ✅ same row broken by SQL → HTTP 422 `NodeAclScopeUnresolved` |
Legs 8 and 11 are the pair that matters: the page and the deploy gate reject the same row for the same
reason, which is the claim "`CheckScopeAsync` mirrors `DraftValidator`" reduces to. Leg 11 also
confirmed the *kind* is honoured on both sides — the row was re-pointed at `RawDriver`, and the gate's
message named `DriverInstance`.
Legs 5 and 9 are the positive controls. Without them the gate passes against a page that refuses
everything, which is the same failure shape stage 6's `resolvedScopes=N` leg exists to catch.
**Not covered:** a UNS-realm scope (SITE-A has no UNS areas, so the picker's UNS column correctly showed
"no UNS areas yet" and only the namespace row was selectable there). `UnsArea` / `UnsLine` / `Equipment`
selection is covered offline only.
Rig restored afterwards: the gate's ACL row deleted and a further deployment sealed, leaving SITE-A at
zero ACL rows as found.
## Known consequence to design around
**Driver-only nodes have no ConfigDb (Phase 4).** Keys and ACLs must therefore reach them inside the