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:
@@ -29,15 +29,28 @@ else if (!IsNew && _existing is null)
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>This grant will not be enforced.</strong>
|
||||
It is saved and shipped in every deployment artifact, but the OPC UA server never evaluates
|
||||
ACL rows — the permission evaluator has no production call site. Nothing you set below will
|
||||
change what a client can read, write, browse or acknowledge.
|
||||
<strong>Only <span class="mono">Cluster</span> scope is enforced today.</strong>
|
||||
Saved with scope kind <span class="mono">Cluster</span>, this grant 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 />
|
||||
Real access control today is fleet-wide LDAP-group → role mapping
|
||||
(<span class="mono">Security:Ldap:GroupToRole</span>):
|
||||
<span class="mono">WriteOperate</span> to write any tag,
|
||||
<span class="mono">AlarmAck</span> to acknowledge any alarm. Reads are ungated.
|
||||
Saved with any narrower scope (<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>,
|
||||
<span class="mono">Raw*</span>) it is stored and shipped in every deployment artifact but
|
||||
<strong>confers nothing</strong> until per-node enforcement lands. That under-granting is
|
||||
deliberate: honouring its 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 />
|
||||
<strong>Principal</strong> is an API-key principal — or the reserved
|
||||
<span class="mono">anonymous</span> principal — not an LDAP group: a client presents an OPC UA
|
||||
UserName token with username <span class="mono">apikey</span> and password
|
||||
<span class="mono">otua_<keyId>_<secret></span>. 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>
|
||||
|
||||
@@ -54,7 +67,7 @@ else
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="grp">Principal</label>
|
||||
<InputText id="grp" @bind-Value="_form.Principal" class="form-control form-control-sm mono"
|
||||
placeholder="cn=Operators,ou=FleetAdmin,dc=zb,dc=local" />
|
||||
placeholder="scadabridge-dcl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -66,14 +79,34 @@ else
|
||||
<option value="@NodeAclScopeKind.UnsArea">UnsArea</option>
|
||||
<option value="@NodeAclScopeKind.UnsLine">UnsLine</option>
|
||||
<option value="@NodeAclScopeKind.Equipment">Equipment</option>
|
||||
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment</option>
|
||||
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment (retired — do not author)</option>
|
||||
<option value="@NodeAclScopeKind.Tag">Tag</option>
|
||||
<option value="@NodeAclScopeKind.RawFolder">RawFolder</option>
|
||||
<option value="@NodeAclScopeKind.RawDriver">RawDriver</option>
|
||||
<option value="@NodeAclScopeKind.RawDevice">RawDevice</option>
|
||||
<option value="@NodeAclScopeKind.RawTagGroup">RawTagGroup</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="target">Scope target ID</label>
|
||||
<InputText id="target" @bind-Value="_form.ScopeId" class="form-control form-control-sm mono"
|
||||
placeholder="Leave blank for cluster-wide" />
|
||||
<div class="form-text">
|
||||
Free text, no picker — a typo saves a grant that resolves to nothing.
|
||||
<span class="mono">Cluster</span> → leave blank ·
|
||||
<span class="mono">Namespace</span> → <span class="mono">raw</span> or
|
||||
<span class="mono">uns</span> ·
|
||||
<span class="mono">UnsArea</span> → a UnsAreaId ·
|
||||
<span class="mono">UnsLine</span> → a UnsLineId ·
|
||||
<span class="mono">Equipment</span> → an EquipmentId ·
|
||||
<span class="mono">Tag</span> → a TagId (a raw tag; the grant covers the raw
|
||||
node and every UNS projection of it) ·
|
||||
<span class="mono">RawFolder</span> → a RawFolderId ·
|
||||
<span class="mono">RawDriver</span> → a DriverInstanceId ·
|
||||
<span class="mono">RawDevice</span> → a DeviceId ·
|
||||
<span class="mono">RawTagGroup</span> → a TagGroupId ·
|
||||
<span class="mono">FolderSegment</span> → retired v2 kind, resolves nothing.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -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_<keyId>_<secret></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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user