feat(acl): variable-depth scope model + the scopePaths producer (#520 stage 3)

Makes the /raw device tree addressable by an ACL rule, and builds the
scope-path producer PermissionTrieBuilder has accepted since it was written
but which had no caller anywhere.

The plan for this stage said "add NodeHierarchyKind.Raw, whose path is the
RawPath segments Folder/Driver/Device/TagGroup/Tag". That shape is wrong.
RawFolder.ParentRawFolderId and TagGroup.ParentTagGroupId are both
self-referencing, so folders and tag groups each nest without bound and a real
RawPath is folder…/Driver/Device/group…/Tag. NodeScope was fixed-arity and
PermissionTrie.CollectMatches was five hardcoded steps, so the scope model
could not address a real raw tree at all. NodeScope is now {ClusterId, Kind,
Segments} and the walk is a segment loop. The trie STRUCTURE was already
variable-depth (NodeAclPath.Segments is a list, Children is a recursive
dictionary), so only the walker and the scope record had to change.

Four decisions worth keeping:

- The namespace level became the realm. Level 1 gated on NodeScope.NamespaceId,
  but v3 has no Namespace entity — the snapshot was retired when the address
  space went dual-namespace, and the identifier appears nowhere in production
  outside NodeScope. So the level could only ever fail to match, which silently
  made every sub-cluster grant unreachable while cluster grants kept working.
  It is now a realm token derived from the scope's Kind, not carried on it, so
  the walk and the producer cannot disagree about its spelling.
- Provenance comes from the granting row's own ScopeKind, recorded on TrieGrant
  at build time. Depth-derived provenance could not name a raw level at all,
  since the depth of "Device" varies with folder nesting.
- The trie comparer moved from OrdinalIgnoreCase to Ordinal. Raw sibling-name
  uniqueness is ordinal, so Speed and speed can both exist under one device and
  a grant on either would have matched both.
- The null-scopePaths "deterministic test mode" is deleted. It planted
  unresolvable rows at a direct child of the trie root, a position the walker
  cannot reach, so the grant looked placed and could never match — and every
  test using that mode proved nothing about production placement. scopePaths is
  now required, and a row that does not resolve is dropped with a diagnostic.

A scope resolves to a LIST of paths: a raw tag projected into N equipments is
reachable at N+1 NodeIds and a write through any of them lands on the same
driver reference, so a Tag-scoped grant covering only the raw node would be
bypassable through a UNS one. The lookup is keyed on (ScopeKind, ScopeId), not
ScopeId alone — ScopeId has no FK and no type discriminator, so an id collision
across entity types would hand a grant the wrong node's paths.

Also closed here, all found by review rather than asked for:

- NodeAcl rows had no deploy-time validation of any kind and were not reachable
  from DraftValidator at all (DraftSnapshot carried no NodeAcls). They are now
  gated for dangling ids, cross-cluster scopes, blank/contradictory ScopeId, the
  retired FolderSegment kind, and undefined ordinals.
- ConfigDbApiKeyStore conferred roles from Cluster-scoped grants authored in ANY
  cluster, because it filtered on scope kind with no ClusterId predicate while
  ArtifactApiKeyStore read an already-cluster-filtered snapshot. On a fused
  admin+driver node that was a cross-cluster over-grant. Its doc comment claimed
  the two stores matched.
- The two AdminUI ACL banners still said enforcement was LDAP-group -> role
  mapping, which stages 1/2 made false in both directions.
- The /apikeys page was never classified in PageAuthorizationGuardTests, so that
  test has been red since the key-minting commit merged — I did not run
  AdminUI.Tests before merging stage 4.

Falsified rather than assumed: 8 mutations against the trie tests, 5 against the
deploy gate (including one proving a cross-cluster row is not silently
mis-diagnosed as unresolved), a staged red->green for the over-grant regression,
and the realm gate, the comparer and the malformed-path guard each broken
individually to confirm only their own tests go red.

Node ACLs are still not ENFORCED — nothing constructs a NodeScope or calls the
evaluator yet. That is stages 5 and 6; this stage makes the model able to
express what those will enforce.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 01:07:59 -04:00
parent fbe7403ca5
commit 3a6b2a1daf
28 changed files with 3063 additions and 363 deletions
@@ -20,29 +20,42 @@
else
{
<div class="alert alert-warning" role="alert">
<strong>These rules are not enforced.</strong>
ACL rows are saved and shipped in every deployment artifact, but the OPC UA server never
evaluates them — the permission evaluator has no production call site. Authoring a grant here
changes nothing about what a client can read or write.
<strong>Only <span class="mono">Cluster</span>-scoped grants are enforced today.</strong>
A Cluster-scoped row confers a server-wide role on its principal:
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
<br />
What <em>is</em> enforced today is coarse and fleet-wide: a client needs the
<span class="mono">WriteOperate</span> role to write any tag and
<span class="mono">AlarmAck</span> to acknowledge any alarm, both mapped from LDAP groups via
<span class="mono">Security:Ldap:GroupToRole</span>. Reads, browses, subscriptions and history
reads are <strong>not</strong> restricted at all. See <span class="mono">docs/security.md</span>.
<strong>Narrower grants confer nothing.</strong>
<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
<span class="mono">Tag</span> and the <span class="mono">Raw*</span> kinds are saved and shipped
in every deployment artifact, but stay inert until per-node enforcement lands. That
under-granting is deliberate: honouring such a grant's flags while its <em>scope</em> cannot be
honoured would turn "WriteOperate on one equipment" into server-wide write authority.
<br />
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
session, including anonymous.
<br />
Identity is an <strong>API key</strong>, not LDAP: a client presents an OPC UA UserName token
with username <span class="mono">apikey</span> and password
<span class="mono">otua_&lt;keyId&gt;_&lt;secret&gt;</span>, and the key resolves to the
principal named on these rows. Mint keys on the <span class="mono">/apikeys</span> page; LDAP
now covers AdminUI login only. See <span class="mono">docs/security.md</span>.
</div>
<section class="panel notice rise" style="animation-delay:.02s">
ACL rows grant principals specific <span class="mono">NodePermissions</span> on a scope
(a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of
fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained per-node scope.
(the cluster, a namespace, an area, an equipment, a raw device, a tag). A principal is an API
key's principal, or the reserved <span class="mono">anonymous</span> principal that an
anonymous session authenticates as.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — default permissions from the fleet-wide LDAP group mapping apply.</div>
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — no principal holds <span class="mono">WriteOperate</span> or <span class="mono">AlarmAck</span> here, so writes and alarm acknowledges are refused. Reads, browses, subscriptions and history reads are unaffected.</div>
}
else
{