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
@@ -78,9 +78,9 @@ string; only what produces that string changes.
| # | #520's blocker | Resolution under this design |
|---|---|---|
| 1 | Raw-realm nodes have no representable `NodeScope` (`NodeHierarchyKind` has one member, `Equipment`) | **Add `NodeHierarchyKind.Raw`**, whose path is the RawPath segments `Folder/Driver/Device/TagGroup/Tag`. `NodeAclScopeKind` already carries `Namespace` and `FolderSegment` — `FolderSegment` becomes matchable for the first time, at raw-folder level. This is the one genuine **design** item, not wiring. |
| 1 | Raw-realm nodes have no representable `NodeScope` (`NodeHierarchyKind` has one member, `Equipment`) | **Add `NodeHierarchyKind.Raw`.** ⚠️ **This cell was wrong about the shape** — it described the path as the five-tuple `Folder/Driver/Device/TagGroup/Tag`, but folders and tag groups both self-nest, so the real path is `folder… / Driver / Device / group… / Tag` with unbounded depth on two axes. The fix was therefore not an enum member but a variable-depth `NodeScope` + segment-loop walk. `FolderSegment` was **not** repurposed as the raw-folder level; it is retired, and `RawFolder`/`RawDriver`/`RawDevice`/`RawTagGroup` were appended instead. Correctly identified as the one genuine **design** item. |
| 2 | Session carries roles, not LDAP groups | **Dissolved by D1** — the principal comes from the key, not from a directory. |
| 3 | `PermissionTrieBuilder`'s `scopePaths` has no producer | Built node-side from the artifact's UNS hierarchy + raw tree. Note every existing evaluator unit test runs in the no-`scopePaths` "deterministic test mode", so **the production hierarchy path is currently untested** — it needs its own coverage, not just a producer. |
| 3 | `PermissionTrieBuilder`'s `scopePaths` has no producer | **Done in stage 3** — `AclScopePathResolver`, built node-side from the artifact's raw + UNS topology (no DB read, so a driver-only node can build it). The coverage note was right in substance but imprecise: six tests *did* pass a `scopePaths` dictionary, but every one hand-wrote it in the test body with exactly the segments the walker expected, so nothing pinned a producer — and there was none to pin. That gap is closed by an artifact → parse → build → walk test. |
| 4 | No session lifecycle / `AuthGenerationId` | Stamp the generation from the artifact `RevisionHash` at session activation; a new deploy invalidates cached decisions. `UserAuthorizationState` already has the freshness predicates. |
Plus, from the same audit: the node never parses ACLs out of the artifact, and `OtOpcUaNodeManager`
@@ -99,11 +99,18 @@ mismatch).
## Smaller inconsistencies to settle en route
- `NodeAclScopeKind.FolderSegment` is offered in the authoring dropdown but the trie walker has no
level to match it at — fixed by blocker #1's `Raw` kind.
level to match it at — **settled in stage 3, but not the way this line assumed.** It is a retired v2
concept (it described the `SystemPlatform`/Galaxy hierarchy v3 deleted), so rather than being given a
level it is now explicitly unresolvable, with `RawFolder` as its v3 replacement. It survives in the
enum only because the artifact stores the ordinal, so removing it would renumber every later member
in every deployed artifact. The dropdown labels it retired, and the deploy gate names it.
⚠️ The old claim that the walker could not match it was slightly overstated: `Build` routed it through
`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.
- `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all — the evaluator is not even
on the node manager's compile-time reference graph.
- ~~`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.
## Stages
@@ -115,8 +122,55 @@ Each stage is one commit, builds clean, and leaves the tree shippable.
2. ✅ **Identity cutover.** `ApiKeyOpcUaUserAuthenticator` replaces `LdapOpcUaUserAuthenticator` on the
OPC UA data plane; Anonymous resolves to the `anonymous` principal or is rejected per setting.
AdminUI LDAP untouched.
3. **Raw scope.** *(not started — sequenced after stage 4 at the owner's request)* `NodeHierarchyKind.Raw` + RawPath scope paths; `scopePaths` producer built from the
artifact; coverage for the production hierarchy path that today has none.
3. **Raw scope.** ✅ **Done.** `NodeHierarchyKind.Raw`, a variable-depth scope model, the `scopePaths`
producer (`AclScopePathResolver`) fed from the artifact, a deploy-time gate for ACL scopes, and the
first coverage of the production hierarchy path.
**It was substantially larger than this line originally implied, because the sketch had the shape of
the raw tree wrong.** The plan said the Raw path is the five-tuple
`Folder/Driver/Device/TagGroup/Tag`. It is not: `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
(five nullable level fields) 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,
IReadOnlyList<string> Segments}` and the walk is a segment loop. Good news found en route: the trie
*structure* was already variable-depth (`NodeAclPath.Segments` is a list; `PermissionTrieNode.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 of the walk gated on `NodeScope.NamespaceId`, but
v3 **has no `Namespace` entity** — the snapshot was retired when the address space went
dual-namespace, and the identifier appeared nowhere in production outside `NodeScope`. So level 1
could only ever fail to match, which silently made *every* sub-cluster grant unreachable while
cluster grants kept working. It is now the realm token (`raw`/`uns`), derived from the scope's
`Kind` by `NodeAclRealms` rather than carried on it, so the walk and the path producer cannot
disagree about its spelling. `NodeAclScopeKind.Namespace` becomes meaningful again as "a whole
realm".
- **Provenance comes from the granting row's `ScopeKind`**, recorded on `TrieGrant` at build time,
not from the depth the grant sits at. Depth-derived provenance could not name a raw level at all
(the depth of "Device" varies with folder nesting) and misreported any path whose length disagreed
with its kind.
- **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 — a silent widening. Case-sensitive is also the fail-closed direction.
- **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. An
unresolvable row is now dropped with a diagnostic, and `scopePaths` is required.
A scope resolves to a **list** of paths, not one: 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 that covered only the raw node would be bypassable through a UNS one. The lookup is keyed on
`(ScopeKind, ScopeId)` rather than `ScopeId` alone, because `ScopeId` has no FK and no type
discriminator — ids are prefixed only by convention, and an id collision across entity types would
hand a grant the wrong node's paths.
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.
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
@@ -1,18 +1,62 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>ACL scope level. Per <c>acl-design.md</c> §"Scope Hierarchy".</summary>
/// <remarks>
/// ⚠️ <b>Append new members only — never insert.</b> SQL Server stores this as a string
/// (<c>nvarchar(16)</c>, so a member name must also stay ≤ 16 characters), but the deployment
/// artifact serialises it as the <b>integer ordinal</b> and the node-side decoder reads it back
/// with <c>Enum.IsDefined</c>. Inserting a member mid-enum silently re-maps every grant in every
/// already-deployed artifact to a different scope level.
/// </remarks>
public enum NodeAclScopeKind
{
/// <summary>The whole cluster — the only kind that carries a null <c>ScopeId</c>.</summary>
Cluster,
Namespace,
UnsArea,
UnsLine,
Equipment,
/// <summary>
/// A folder segment anchored below a namespace (used for FolderPath-scoped tags).
/// Distinguishes folder grants from UNS <see cref="Equipment"/> grants in the
/// <c>AuthorizationDecision.Provenance</c> audit trail and Admin UI diagnostics.
/// One whole OPC UA namespace (realm). <c>ScopeId</c> is the realm token <c>raw</c> or
/// <c>uns</c>; see <c>NodeAclRealms</c>, which is the single authority for that spelling.
/// </summary>
Namespace,
/// <summary>A UNS area. <c>ScopeId</c> is a <c>UnsArea.UnsAreaId</c>.</summary>
UnsArea,
/// <summary>A UNS line. <c>ScopeId</c> is a <c>UnsLine.UnsLineId</c>.</summary>
UnsLine,
/// <summary>A UNS equipment. <c>ScopeId</c> is an <c>Equipment.EquipmentId</c>.</summary>
Equipment,
/// <summary>
/// ⚠️ <b>Retired v2 concept — do not author new rows with this kind.</b> It described a folder
/// segment under the v2 <c>SystemPlatform</c>/Galaxy hierarchy, which v3 deleted along with the
/// <c>WalkSystemPlatform</c> walker. The v3 replacement is <see cref="RawFolder"/>. The member
/// survives only because removing it would shift every later ordinal in already-deployed
/// artifacts; a row still carrying it resolves no scope path and is reported as an unresolved
/// scope by the deploy gate.
/// </summary>
FolderSegment,
/// <summary>
/// A single tag. <c>ScopeId</c> is a <c>Tag.TagId</c> — i.e. a <b>raw</b> tag, which is the only
/// kind of tag v3 has. Because one raw tag can be projected into several equipments by
/// <c>UnsTagReference</c> rows, a Tag-scoped grant resolves to <b>several</b> trie paths: the raw
/// node plus every UNS projection of it. That is deliberate — a write routes to the same driver
/// reference through either NodeId, so a grant that covered only one of them would be bypassable
/// through the other.
/// </summary>
Tag,
/// <summary>A raw-tree folder. <c>ScopeId</c> is a <c>RawFolder.RawFolderId</c>. (v3; appended.)</summary>
RawFolder,
/// <summary>A raw-tree driver instance. <c>ScopeId</c> is a <c>DriverInstance.DriverInstanceId</c>. (v3; appended.)</summary>
RawDriver,
/// <summary>A raw-tree device. <c>ScopeId</c> is a <c>Device.DeviceId</c>. (v3; appended.)</summary>
RawDevice,
/// <summary>A raw-tree tag group. <c>ScopeId</c> is a <c>TagGroup.TagGroupId</c>. (v3; appended.)</summary>
RawTagGroup,
}
@@ -62,6 +62,10 @@ public sealed class DraftSnapshot
/// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
/// <summary>ACL grants. Drive the scope-resolution rule: a scope that names nothing deployed is
/// silently dropped when the permission trie is built, so the grant has to be caught here instead.</summary>
public IReadOnlyList<NodeAcl> NodeAcls { get; init; } = [];
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
@@ -52,6 +52,7 @@ public static class DraftSnapshotFactory
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
.AsNoTracking()
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -42,9 +43,235 @@ public static class DraftValidator
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
ValidateNodeAclScopes(draft, errors);
return errors;
}
/// <summary>
/// #520 stage 3: every <see cref="NodeAcl"/> row's scope must name something the deployed address
/// space actually contains.
/// </summary>
/// <remarks>
/// <para>
/// ACL rows have had <b>no</b> validation anywhere until now, and the failure mode is the worst
/// shape a security control can have: <c>AclScopePathResolver</c> returns no path for a scope it
/// cannot resolve, the trie builder then drops the grant with a build-time diagnostic, and the
/// deploy still seals green. An operator who mistypes a <c>ScopeId</c>, or scopes a grant at an
/// entity someone later deleted, gets a row that reads as a live grant in the AdminUI and
/// authorizes nothing at runtime. Silently dropping a grant is fail-closed and therefore safe,
/// but it is not <em>visible</em> — which is why the check belongs at the deploy gate, where the
/// author is still standing in front of the mistake.
/// </para>
/// <para>
/// <b>The realm tokens are duplicated here, deliberately.</b> <c>NodeAclRealms</c> in
/// <c>Core.Authorization</c> is the authority for their spelling, but <c>Core</c> references
/// <c>Configuration</c> and not the reverse, so this project cannot reach it. The two namespace
/// <em>URIs</em> come from <see cref="V3NodeIds"/> in <c>Commons</c>, which both sides do share;
/// only the two short tokens are literals. If a third realm is ever added, it must be added in
/// both places — the accepted cost of not inverting the project graph for one gate.
/// </para>
/// <para>
/// Existence is checked with the same ordinal, untrimmed comparison
/// <c>AclScopePathResolver</c>'s dictionary lookups use, so a row this rule passes is a row that
/// resolver resolves. The realm check mirrors <c>NodeAclRealms.TryCanonicalize</c> instead
/// (trimmed, case-insensitive) for the same reason.
/// </para>
/// <para>
/// <b>Containment is checked as well as existence.</b> A grant in cluster A naming an entity that
/// belongs to cluster B is not the "names nothing" failure — it resolves to a real trie path, and
/// so it silently confers authority across a cluster boundary. It gets its own code because the
/// remedy is different: an unresolved id is a typo or a deletion, a cross-cluster id is a grant
/// authored against the wrong cluster. The lookups are deliberately <b>fleet-wide, not
/// cluster-filtered</b> — a per-cluster lookup would make a cross-cluster id present as merely
/// unresolved, which is the wrong diagnosis and would leave this check unable to ever fire.
/// </para>
/// <para>
/// Ownership is not a uniform property. <c>RawFolder</c>, <c>DriverInstance</c> and <c>UnsArea</c>
/// carry <c>ClusterId</c>; <c>Device</c>, <c>TagGroup</c>, <c>Tag</c>, <c>UnsLine</c> and
/// <c>Equipment</c> do not, and inherit it through their parent. Both chains are at most three
/// hops and neither can loop (<c>TagGroup</c> and <c>RawFolder</c> nest, but both reach the
/// cluster through a non-nesting link — <c>DeviceId</c> and <c>ClusterId</c> respectively), so
/// unlike the resolver's ancestry walks these need no depth guard. A broken chain means the
/// owning cluster is unknowable, which is the same state as unresolvable: it reports
/// <c>NodeAclScopeUnresolved</c> and never both codes.
/// </para>
/// </remarks>
private static void ValidateNodeAclScopes(DraftSnapshot draft, List<ValidationError> errors)
{
if (draft.NodeAcls.Count == 0) return;
// One map per ancestry link, built once for the whole pass. Their key sets double as the existence
// sets, so "does this id name a row of the kind's type" and "which cluster owns it" cannot disagree.
// GroupBy/First rather than ToDictionary: a duplicated id is another rule's business, and throwing
// here would take down every remaining check in the same pass.
var folderCluster = Link(draft.RawFolders, f => f.RawFolderId, f => f.ClusterId);
var driverCluster = Link(draft.DriverInstances, d => d.DriverInstanceId, d => d.ClusterId);
var deviceDriver = Link(draft.Devices, d => d.DeviceId, d => d.DriverInstanceId);
var groupDevice = Link(draft.TagGroups, g => g.TagGroupId, g => g.DeviceId);
var tagDevice = Link(draft.Tags, t => t.TagId, t => t.DeviceId);
var areaCluster = Link(draft.UnsAreas, a => a.UnsAreaId, a => a.ClusterId);
var lineArea = Link(draft.UnsLines, l => l.UnsLineId, l => l.UnsAreaId);
var equipLine = Link(draft.Equipment, e => e.EquipmentId, e => e.UnsLineId);
static Dictionary<string, string> Link<T>(
IReadOnlyList<T> rows, Func<T, string> key, Func<T, string> value) =>
rows.GroupBy(key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => value(g.First()), StringComparer.Ordinal);
// Owning cluster, or null when a link in the chain is dangling. Direct-ClusterId kinds bottom out
// immediately; the rest hop through their parent.
string? ClusterOfDevice(string deviceId) =>
deviceDriver.TryGetValue(deviceId, out var driverId) ? driverCluster.GetValueOrDefault(driverId) : null;
string? ClusterOfLine(string lineId) =>
lineArea.TryGetValue(lineId, out var areaId) ? areaCluster.GetValueOrDefault(areaId) : null;
foreach (var acl in draft.NodeAcls)
{
var id = acl.NodeAclId;
// An ordinal outside the enum comes from a hand-edited row or a schema the code no longer
// matches. Every later check switches on the kind, so nothing below it would be meaningful.
if (!Enum.IsDefined(acl.ScopeKind))
{
errors.Add(new("NodeAclScopeKindUndefined",
$"NodeAcl '{id}' has ScopeKind ordinal {(int)acl.ScopeKind}, which is not a defined " +
"NodeAclScopeKind; the row cannot be evaluated. Re-author it with a valid scope kind.",
id));
continue;
}
if (acl.ScopeKind == NodeAclScopeKind.Cluster)
{
// Cluster is the one kind with nothing to point at, so a ScopeId here means the author
// intended a narrower scope and picked the wrong kind — the grant is cluster-wide either
// way, i.e. broader than intended, which is the direction that must never pass silently.
if (!string.IsNullOrWhiteSpace(acl.ScopeId))
errors.Add(new("NodeAclScopeIdUnexpected",
$"NodeAcl '{id}' is Cluster-scoped but carries ScopeId '{acl.ScopeId}'. A Cluster grant " +
"covers the whole cluster and ignores ScopeId; clear it, or change ScopeKind to the level " +
"the id names.",
id));
continue;
}
// Ahead of the missing-ScopeId check on purpose: the retirement is the actionable fact, and
// telling someone to supply a ScopeId for a kind that can never resolve sends them the wrong way.
if (acl.ScopeKind == NodeAclScopeKind.FolderSegment)
{
errors.Add(new("NodeAclScopeKindRetired",
$"NodeAcl '{id}' uses the retired v2 scope kind FolderSegment, which has no v3 entity behind " +
"it and can never resolve, whatever its ScopeId. Re-scope the grant to RawFolder (the v3 " +
"replacement) and point ScopeId at the RawFolderId that replaced the old folder segment.",
id));
continue;
}
if (string.IsNullOrWhiteSpace(acl.ScopeId))
{
errors.Add(new("NodeAclScopeIdMissing",
$"NodeAcl '{id}' has ScopeKind {acl.ScopeKind} but no ScopeId. Only Cluster grants may omit " +
"it; every other kind resolves no scope path without one and the grant is dropped at build " +
"time. Set the ScopeId, or change ScopeKind to Cluster.",
id));
continue;
}
var scopeId = acl.ScopeId!;
if (acl.ScopeKind == NodeAclScopeKind.Namespace)
{
if (CanonicalRealm(scopeId) is null)
errors.Add(new("NodeAclNamespaceInvalid",
$"NodeAcl '{id}' is Namespace-scoped but ScopeId '{scopeId}' names no OPC UA namespace. " +
$"Use '{RealmRaw}' or '{RealmUns}' (or the full URI '{V3NodeIds.RawNamespaceUri}' / " +
$"'{V3NodeIds.UnsNamespaceUri}').",
id));
continue;
}
// Each arm names the entity type the kind's ScopeId is drawn from, whether a row with that id
// exists at all, and which cluster owns it. Switch arms evaluate lazily, so only the matched
// kind's chain is walked.
var (entity, exists, owner) = acl.ScopeKind switch
{
NodeAclScopeKind.UnsArea =>
("UnsArea", areaCluster.ContainsKey(scopeId), areaCluster.GetValueOrDefault(scopeId)),
NodeAclScopeKind.UnsLine =>
("UnsLine", lineArea.ContainsKey(scopeId), ClusterOfLine(scopeId)),
NodeAclScopeKind.Equipment =>
("Equipment", equipLine.ContainsKey(scopeId),
equipLine.TryGetValue(scopeId, out var eqLine) ? ClusterOfLine(eqLine) : null),
NodeAclScopeKind.Tag =>
("Tag", tagDevice.ContainsKey(scopeId),
tagDevice.TryGetValue(scopeId, out var tagDev) ? ClusterOfDevice(tagDev) : null),
NodeAclScopeKind.RawFolder =>
("RawFolder", folderCluster.ContainsKey(scopeId), folderCluster.GetValueOrDefault(scopeId)),
NodeAclScopeKind.RawDriver =>
("DriverInstance", driverCluster.ContainsKey(scopeId), driverCluster.GetValueOrDefault(scopeId)),
NodeAclScopeKind.RawDevice =>
("Device", deviceDriver.ContainsKey(scopeId), ClusterOfDevice(scopeId)),
NodeAclScopeKind.RawTagGroup =>
("TagGroup", groupDevice.ContainsKey(scopeId),
groupDevice.TryGetValue(scopeId, out var grpDev) ? ClusterOfDevice(grpDev) : null),
// Cluster / Namespace / FolderSegment all returned above; the undefined case did too.
_ => (string.Empty, true, acl.ClusterId),
};
if (!exists)
{
errors.Add(new("NodeAclScopeUnresolved",
$"NodeAcl '{id}' is scoped at {acl.ScopeKind} '{scopeId}', which is not a {entity} in this " +
"configuration. The grant would be dropped at build time and authorize nothing. Correct the " +
"ScopeId, or delete the grant if the entity was removed.",
id));
continue;
}
if (owner is null)
{
// The row exists but its parent chain is dangling, so it has no place in any cluster's tree
// and resolves to no path — the same outcome as a missing row, reported under the same code.
errors.Add(new("NodeAclScopeUnresolved",
$"NodeAcl '{id}' is scoped at {acl.ScopeKind} '{scopeId}', which exists but whose parent " +
$"chain is broken, so no cluster owns it and it resolves to no scope path. Repair the {entity}'s " +
"parent reference, or delete the grant.",
id));
continue;
}
if (!string.Equals(owner, acl.ClusterId, StringComparison.Ordinal))
errors.Add(new("NodeAclScopeCrossCluster",
$"NodeAcl '{id}' is a grant in cluster '{acl.ClusterId}' but is scoped at {acl.ScopeKind} " +
$"'{scopeId}', which belongs to cluster '{owner}'. The id is real, so the grant would resolve " +
"and confer authority across a cluster boundary. Re-author the grant under cluster " +
$"'{owner}', or point ScopeId at the matching {entity} in '{acl.ClusterId}'.",
id));
}
}
// Realm tokens — see ValidateNodeAclScopes for why these are duplicated rather than referenced.
// Core.Authorization.NodeAclRealms is the authority; keep the two in step.
private const string RealmRaw = "raw";
private const string RealmUns = "uns";
/// <summary>Mirror of <c>NodeAclRealms.TryCanonicalize</c>: accepts either short realm token or either
/// full namespace URI, trimmed and case-insensitive. Null when the id names no realm.</summary>
private static string? CanonicalRealm(string scopeId)
{
var trimmed = scopeId.Trim();
if (string.Equals(trimmed, RealmRaw, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, V3NodeIds.RawNamespaceUri, StringComparison.OrdinalIgnoreCase))
return RealmRaw;
if (string.Equals(trimmed, RealmUns, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, V3NodeIds.UnsNamespaceUri, StringComparison.OrdinalIgnoreCase))
return RealmUns;
return null;
}
/// <summary>
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
/// at the deploy gate (Gitea #498).
@@ -0,0 +1,288 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Identifies a scope for path lookup. Keyed on the kind <b>as well as</b> the id.
/// </summary>
/// <remarks>
/// The kind is part of the key because <c>NodeAcl.ScopeId</c> is a bare string with no foreign key
/// and no type discriminator of its own — ids are prefixed by convention (<c>RF…</c>, <c>EQ…</c>)
/// but nothing enforces it. Keying on the id alone would let a <c>DeviceId</c> that happened to
/// equal a <c>UnsAreaId</c> hand a grant the other entity's paths, which is a silent
/// wrong-node authorization rather than a visible error.
/// </remarks>
/// <param name="Kind">The authored scope kind.</param>
/// <param name="ScopeId">The authored scope id.</param>
public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId);
/// <summary>
/// Produces the <c>scopePaths</c> lookup <see cref="PermissionTrieBuilder.Build"/> consumes: for each
/// ACL scope, the trie path(s) at which a grant on it must be planted (#520 stage 3).
/// </summary>
/// <remarks>
/// <para>
/// Pure and input-shape agnostic, in the same spirit as <see cref="RawPathResolver"/>: callers
/// build the ancestry maps from whatever they hold — EF entities on the authoring / deploy-gate
/// side, artifact JSON on the node side — so both sides produce identical paths without sharing a
/// database. That matters because a driver-only node has no ConfigDb at all (per-cluster mesh
/// Phase 4), so anything requiring a DB read node-side is disqualified outright.
/// </para>
/// <para>
/// Raw tag paths are delegated to <see cref="RawPathResolver"/> rather than recomputed, so an ACL
/// path and the RawPath that forms the node's actual NodeId cannot drift apart. Container paths
/// (folder / driver / device / group) are composed here from the same chains in the same order,
/// which keeps every container path a true prefix of the tags beneath it — the property the trie
/// walk depends on for a container grant to cascade.
/// </para>
/// <para>
/// A broken or dangling link yields <b>no path</b> for that scope, never a throw: the grant is then
/// dropped by the builder with an <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/>
/// diagnostic. Fail-closed, and consistent with the resolver family's "a malformed artifact drops
/// one entry rather than faulting the whole decode" rule.
/// </para>
/// </remarks>
public sealed class AclScopePathResolver
{
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _folders;
private readonly IReadOnlyDictionary<string, (string? RawFolderId, string Name)> _drivers;
private readonly IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> _devices;
private readonly IReadOnlyDictionary<string, (string? ParentId, string DeviceId, string Name)> _groups;
private readonly IReadOnlyDictionary<string, (string DeviceId, string? TagGroupId, string Name)> _tags;
private readonly IReadOnlyDictionary<string, string> _areas;
private readonly IReadOnlyDictionary<string, (string UnsAreaId, string Name)> _lines;
private readonly IReadOnlyDictionary<string, (string UnsLineId, string Name)> _equipment;
private readonly IReadOnlyList<(string EquipmentId, string TagId, string EffectiveName)> _references;
private readonly RawPathResolver _rawPaths;
/// <summary>Initializes a new <see cref="AclScopePathResolver"/> over the deployed topology.</summary>
/// <param name="folders"><c>RawFolderId → (ParentRawFolderId?, Name)</c>.</param>
/// <param name="drivers"><c>DriverInstanceId → (RawFolderId?, Name)</c>.</param>
/// <param name="devices"><c>DeviceId → (DriverInstanceId, Name)</c>.</param>
/// <param name="groups"><c>TagGroupId → (ParentTagGroupId?, DeviceId, Name)</c>.</param>
/// <param name="tags"><c>TagId → (DeviceId, TagGroupId?, Name)</c>.</param>
/// <param name="areas"><c>UnsAreaId → Name</c>.</param>
/// <param name="lines"><c>UnsLineId → (UnsAreaId, Name)</c>.</param>
/// <param name="equipment"><c>EquipmentId → (UnsLineId, Name)</c>.</param>
/// <param name="references">The UNS projections: <c>(EquipmentId, TagId, EffectiveName)</c>.</param>
public AclScopePathResolver(
IReadOnlyDictionary<string, (string? ParentId, string Name)> folders,
IReadOnlyDictionary<string, (string? RawFolderId, string Name)> drivers,
IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> devices,
IReadOnlyDictionary<string, (string? ParentId, string DeviceId, string Name)> groups,
IReadOnlyDictionary<string, (string DeviceId, string? TagGroupId, string Name)> tags,
IReadOnlyDictionary<string, string> areas,
IReadOnlyDictionary<string, (string UnsAreaId, string Name)> lines,
IReadOnlyDictionary<string, (string UnsLineId, string Name)> equipment,
IReadOnlyList<(string EquipmentId, string TagId, string EffectiveName)> references)
{
ArgumentNullException.ThrowIfNull(folders);
ArgumentNullException.ThrowIfNull(drivers);
ArgumentNullException.ThrowIfNull(devices);
ArgumentNullException.ThrowIfNull(groups);
ArgumentNullException.ThrowIfNull(tags);
ArgumentNullException.ThrowIfNull(areas);
ArgumentNullException.ThrowIfNull(lines);
ArgumentNullException.ThrowIfNull(equipment);
ArgumentNullException.ThrowIfNull(references);
_folders = folders;
_drivers = drivers;
_devices = devices;
_groups = groups;
_tags = tags;
_areas = areas;
_lines = lines;
_equipment = equipment;
_references = references;
_rawPaths = new RawPathResolver(
folders,
drivers,
devices,
groups.ToDictionary(kv => kv.Key, kv => (kv.Value.ParentId, kv.Value.Name), StringComparer.Ordinal));
}
/// <summary>
/// Resolve the trie path(s) for every distinct scope named by <paramref name="rows"/>. Scopes that
/// resolve to nothing are absent from the result, which is what makes the builder drop them.
/// </summary>
/// <param name="rows">The ACL rows whose scopes need resolving.</param>
/// <returns>The <c>scopePaths</c> lookup.</returns>
public IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> Resolve(IReadOnlyList<NodeAcl> rows)
{
ArgumentNullException.ThrowIfNull(rows);
var result = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
foreach (var row in rows)
{
if (row.ScopeKind == NodeAclScopeKind.Cluster || string.IsNullOrEmpty(row.ScopeId)) continue;
var key = new AclScopeRef(row.ScopeKind, row.ScopeId);
if (result.ContainsKey(key)) continue;
var paths = ResolveOne(row.ScopeKind, row.ScopeId);
if (paths.Count > 0) result[key] = paths;
}
return result;
}
/// <summary>Resolve one scope to its trie path(s). Empty when the scope names nothing deployed.</summary>
/// <param name="kind">The scope kind.</param>
/// <param name="scopeId">The scope id.</param>
/// <returns>The resolved paths, possibly empty.</returns>
public IReadOnlyList<NodeAclPath> ResolveOne(NodeAclScopeKind kind, string scopeId)
{
if (string.IsNullOrEmpty(scopeId)) return [];
switch (kind)
{
case NodeAclScopeKind.Namespace:
var realm = NodeAclRealms.TryCanonicalize(scopeId);
return realm is null ? [] : [new NodeAclPath([realm])];
case NodeAclScopeKind.UnsArea:
return _areas.TryGetValue(scopeId, out var areaName)
? [UnsPath([areaName])]
: [];
case NodeAclScopeKind.UnsLine:
return TryUnsLineSegments(scopeId) is { } lineSegs ? [UnsPath(lineSegs)] : [];
case NodeAclScopeKind.Equipment:
return TryEquipmentSegments(scopeId) is { } eqSegs ? [UnsPath(eqSegs)] : [];
case NodeAclScopeKind.Tag:
return ResolveTag(scopeId);
case NodeAclScopeKind.RawFolder:
return TryFolderChain(scopeId) is { } folderSegs ? [RawPath(folderSegs)] : [];
case NodeAclScopeKind.RawDriver:
return TryDriverSegments(scopeId) is { } driverSegs ? [RawPath(driverSegs)] : [];
case NodeAclScopeKind.RawDevice:
return TryDeviceSegments(scopeId) is { } deviceSegs ? [RawPath(deviceSegs)] : [];
case NodeAclScopeKind.RawTagGroup:
return TryGroupSegments(scopeId) is { } groupSegs ? [RawPath(groupSegs)] : [];
// Cluster never reaches here (the caller short-circuits it), and FolderSegment is the retired
// v2 kind with no v3 entity behind it — deliberately unresolvable, so an operator who still
// has such a row gets a diagnostic naming it rather than a grant that quietly does nothing.
case NodeAclScopeKind.Cluster:
case NodeAclScopeKind.FolderSegment:
default:
return [];
}
}
private IReadOnlyList<NodeAclPath> ResolveTag(string tagId)
{
var paths = new List<NodeAclPath>();
if (_tags.TryGetValue(tagId, out var tag))
{
var rawPath = _rawPaths.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
if (rawPath is not null)
paths.Add(RawPath(rawPath.Split(RawPaths.Separator)));
}
// Every UNS projection of this raw tag. A tag referenced by three equipments is reachable at four
// NodeIds, and a write through any of them lands on the same driver reference — so a Tag-scoped
// grant has to cover all four or it is bypassable through whichever one it missed.
foreach (var reference in _references)
{
if (!string.Equals(reference.TagId, tagId, StringComparison.Ordinal)) continue;
if (TryEquipmentSegments(reference.EquipmentId) is not { } equipSegs) continue;
paths.Add(UnsPath([.. equipSegs, reference.EffectiveName]));
}
return paths;
}
private string[]? TryUnsLineSegments(string lineId)
{
if (!_lines.TryGetValue(lineId, out var line)) return null;
if (!_areas.TryGetValue(line.UnsAreaId, out var areaName)) return null;
return [areaName, line.Name];
}
private string[]? TryEquipmentSegments(string equipmentId)
{
if (!_equipment.TryGetValue(equipmentId, out var equip)) return null;
if (TryUnsLineSegments(equip.UnsLineId) is not { } lineSegs) return null;
return [.. lineSegs, equip.Name];
}
private string[]? TryFolderChain(string? leafFolderId)
{
if (leafFolderId is null) return [];
var chain = new List<string>();
var current = leafFolderId;
var guard = 0;
// Depth-bounded like RawPathResolver's own walks: this is a corrupt-parent-cycle guard, not a
// schema limit — raw folder nesting is unbounded by design.
while (current is not null)
{
if (++guard > MaxAncestryDepth) return null;
if (!_folders.TryGetValue(current, out var folder)) return null;
chain.Add(folder.Name);
current = folder.ParentId;
}
chain.Reverse();
return [.. chain];
}
private string[]? TryDriverSegments(string driverInstanceId)
{
if (!_drivers.TryGetValue(driverInstanceId, out var driver)) return null;
if (TryFolderChain(driver.RawFolderId) is not { } folderSegs) return null;
return [.. folderSegs, driver.Name];
}
private string[]? TryDeviceSegments(string deviceId)
{
if (!_devices.TryGetValue(deviceId, out var device)) return null;
if (TryDriverSegments(device.DriverInstanceId) is not { } driverSegs) return null;
return [.. driverSegs, device.Name];
}
private string[]? TryGroupSegments(string tagGroupId)
{
if (!_groups.TryGetValue(tagGroupId, out var group)) return null;
if (TryDeviceSegments(group.DeviceId) is not { } deviceSegs) return null;
var chain = new List<string>();
string? current = tagGroupId;
var guard = 0;
while (current is not null)
{
if (++guard > MaxAncestryDepth) return null;
if (!_groups.TryGetValue(current, out var node)) return null;
chain.Add(node.Name);
current = node.ParentId;
}
chain.Reverse();
return [.. deviceSegs, .. chain];
}
// No params-array overloads here: string[] binds to both params and IEnumerable, and the resulting
// ambiguity would be resolved silently by overload rules rather than by intent.
private static NodeAclPath RawPath(IEnumerable<string> segments) =>
new([NodeAclRealms.Raw, .. segments]);
private static NodeAclPath UnsPath(IEnumerable<string> segments) =>
new([NodeAclRealms.Uns, .. segments]);
private const int MaxAncestryDepth = 256;
}
@@ -22,7 +22,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
public sealed class DataPlaneAuthoritySnapshot
{
/// <summary>The state of a node that has not yet applied an artifact. Authenticates nothing.</summary>
public static readonly DataPlaneAuthoritySnapshot Empty = Create(null, string.Empty, 0, [], []);
public static readonly DataPlaneAuthoritySnapshot Empty =
Create(null, string.Empty, 0, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
private DataPlaneAuthoritySnapshot(
string? clusterId,
@@ -30,6 +31,7 @@ public sealed class DataPlaneAuthoritySnapshot
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, ApiKey> keysById,
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
{
@@ -38,6 +40,7 @@ public sealed class DataPlaneAuthoritySnapshot
GenerationId = generationId;
Keys = keys;
Grants = grants;
ScopePaths = scopePaths;
_keysById = keysById;
_grantsByPrincipal = grantsByPrincipal;
}
@@ -66,6 +69,13 @@ public sealed class DataPlaneAuthoritySnapshot
/// <summary>Every ACL grant scoped to this node's cluster.</summary>
public IReadOnlyList<NodeAcl> Grants { get; }
/// <summary>
/// Where each grant's scope sits in the address space, resolved from the same artifact the grants
/// came from. Feeds <see cref="PermissionTrieBuilder.Build"/>; a scope absent here has no
/// deployed node and its grant is dropped rather than planted somewhere unreachable.
/// </summary>
public IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths { get; }
/// <summary>True once a real artifact has been applied.</summary>
public bool HasArtifact => GenerationId > 0;
@@ -101,17 +111,20 @@ public sealed class DataPlaneAuthoritySnapshot
/// <param name="generationId">The node-local generation stamp.</param>
/// <param name="keys">The API keys carried by the artifact.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
/// <returns>The built snapshot.</returns>
public static DataPlaneAuthoritySnapshot Create(
string? clusterId,
string revisionHash,
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants)
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
{
ArgumentNullException.ThrowIfNull(revisionHash);
ArgumentNullException.ThrowIfNull(keys);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(scopePaths);
// OrdinalIgnoreCase throughout: key ids are minted as lowercase hex, but a client hand-copying a
// key out of the AdminUI can upper-case it, and the principal comparison must agree with the
@@ -140,7 +153,8 @@ public sealed class DataPlaneAuthoritySnapshot
kv => (IReadOnlyList<NodeAcl>)kv.Value,
StringComparer.OrdinalIgnoreCase);
return new DataPlaneAuthoritySnapshot(clusterId, revisionHash, generationId, keys, grants, byId, frozen);
return new DataPlaneAuthoritySnapshot(
clusterId, revisionHash, generationId, keys, grants, scopePaths, byId, frozen);
}
}
@@ -179,12 +193,14 @@ public interface IDataPlaneAuthoritySink
/// <param name="revisionHash">SHA-256 of the artifact bytes.</param>
/// <param name="keys">The API keys the artifact carries.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
/// <returns>The installed snapshot.</returns>
DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants);
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths);
}
/// <summary>
@@ -204,7 +220,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants)
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
{
ArgumentNullException.ThrowIfNull(revisionHash);
@@ -227,7 +244,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
? previous.GenerationId
: previous.GenerationId + 1;
var next = DataPlaneAuthoritySnapshot.Create(clusterId, revisionHash, generation, keys, grants);
var next = DataPlaneAuthoritySnapshot.Create(
clusterId, revisionHash, generation, keys, grants, scopePaths);
Volatile.Write(ref _current, next);
return next;
}
@@ -1,47 +1,146 @@
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Address of a node in the 6-level scope hierarchy the Phase 6.2 evaluator walks.
/// Assembled by the dispatch layer from the node's namespace + UNS path + tag; passed
/// to <see cref="IPermissionEvaluator"/> which walks the matching trie path.
/// Address of a node in the ACL scope hierarchy the evaluator walks: a cluster, a realm, and the
/// node's path within that realm. Assembled by the dispatch layer from the node's NodeId; passed to
/// <see cref="IPermissionEvaluator"/>, which walks the matching trie path.
/// </summary>
/// <remarks>
/// <para>Per the Phase 6.2 Stream B plan the hierarchy is
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> for all
/// (Equipment-kind) namespaces. Galaxy is a standard Equipment-kind driver, so Galaxy
/// points are ordinary equipment tags that resolve through this same walk.</para>
///
/// <para>Unset mid-path levels (e.g. a Cluster-scoped request with no UnsArea) leave
/// the corresponding id <c>null</c>. The evaluator walks as far as the scope goes +
/// stops at the first null.</para>
/// <para>
/// The hierarchy is <c>Cluster → Realm → …node path…</c>. The node path is <b>variable length</b>,
/// which is the whole reason this type is no longer a fixed set of level properties (#520 stage 3):
/// </para>
/// <list type="bullet">
/// <item>
/// <b>UNS</b> paths are always <c>Area / Line / Equipment [/ EffectiveName]</c> — fixed depth,
/// because equipment is a tree leaf.
/// </item>
/// <item>
/// <b>Raw</b> paths are <c>folder… / Driver / Device / group… / Tag</c>, and BOTH the folder
/// chain and the tag-group chain nest arbitrarily deep (<c>RawFolder.ParentRawFolderId</c>,
/// <c>TagGroup.ParentTagGroupId</c>). There is no schema depth cap — the 256 in
/// <c>RawPathResolver</c> is a corrupt-cycle guard, not a limit. A fixed-arity scope record
/// therefore cannot address a real raw tree at all; the previous six-property shape silently
/// capped it at five levels and mislabelled every one of them.
/// </item>
/// </list>
/// <para>
/// The realm segment is NOT carried here. It is derived from <see cref="Kind"/> by
/// <see cref="NodeAclRealms.SegmentFor(NodeHierarchyKind)"/>, which the scope-path producer also uses — so the two
/// sides cannot disagree about its spelling. That matters more than it looks: the realm is a hard
/// gate at level 1 of the walk, so a mismatch would make every sub-cluster grant in the system
/// unreachable while cluster-scoped grants kept working, which reads as "ACLs work" right up until
/// someone tries to narrow one.
/// </para>
/// </remarks>
public sealed record NodeScope
{
/// <summary>Cluster the node belongs to. Required.</summary>
public required string ClusterId { get; init; }
/// <summary>Namespace within the cluster. Null is not allowed for a request against a real node.</summary>
public string? NamespaceId { get; init; }
/// <summary>UNS area (e.g. "warsaw-west") below the namespace.</summary>
public string? UnsAreaId { get; init; }
/// <summary>UNS line below the area.</summary>
public string? UnsLineId { get; init; }
/// <summary>Equipment row below the line.</summary>
public string? EquipmentId { get; init; }
/// <summary>Target tag id when the scope addresses a specific tag; null for equipment-level scopes.</summary>
public string? TagId { get; init; }
/// <summary>Which hierarchy applies. Currently only Equipment-kind (UNS) exists.</summary>
/// <summary>Which realm — and therefore which hierarchy shape — the node lives in.</summary>
public required NodeHierarchyKind Kind { get; init; }
/// <summary>
/// The node's path within its realm, root-first, excluding the realm segment itself. Empty
/// addresses the realm as a whole. Segment comparison is case-sensitive ordinal, matching
/// <c>RawPaths.Comparer</c> and OPC UA NodeId identity.
/// </summary>
public required IReadOnlyList<string> Segments { get; init; }
}
/// <summary>Selector for the scope-hierarchy shape.</summary>
/// <summary>Selector for the scope-hierarchy shape — one member per OPC UA namespace (realm).</summary>
public enum NodeHierarchyKind
{
/// <summary><c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> — UNS / Equipment kind.</summary>
Equipment,
/// <summary><c>Area / Line / Equipment [/ EffectiveName]</c> — the UNS realm.</summary>
Uns,
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended, #520 stage 3.)</summary>
Raw,
}
/// <summary>
/// The single authority for the realm segment that sits at level 1 of every permission trie, directly
/// under the cluster root.
/// </summary>
/// <remarks>
/// <para>
/// This exists because v3 <b>has no <c>Namespace</c> entity</b>. The snapshot was retired when the
/// address space went dual-namespace ("the two OPC UA namespaces are implicit"), which left the
/// trie's level-1 gate with no configured key source at all — the pre-stage-3 walker read a
/// <c>NamespaceId</c> that nothing in the system ever produced, so the level could only ever fail
/// to match. Deriving the segment from the realm restores the level as something real without
/// reintroducing an entity.
/// </para>
/// <para>
/// The short tokens, rather than the namespace URIs, are deliberate: they are what an operator
/// types into a <see cref="Configuration.Enums.NodeAclScopeKind.Namespace"/> row's <c>ScopeId</c>,
/// and they read clearly in a diagnostic. They cannot collide with a user-authored segment,
/// because only a realm token is ever placed at level 1 — real names start at level 2.
/// </para>
/// </remarks>
public static class NodeAclRealms
{
/// <summary>Realm segment for the raw device tree (<c>https://zb.com/otopcua/raw</c>).</summary>
public const string Raw = "raw";
/// <summary>Realm segment for the UNS tree (<c>https://zb.com/otopcua/uns</c>).</summary>
public const string Uns = "uns";
/// <summary>The realm segment for a hierarchy kind.</summary>
/// <param name="kind">The hierarchy kind.</param>
/// <returns>The level-1 trie segment.</returns>
/// <exception cref="ArgumentOutOfRangeException">The kind is not a defined member.</exception>
public static string SegmentFor(NodeHierarchyKind kind) => kind switch
{
NodeHierarchyKind.Uns => Uns,
NodeHierarchyKind.Raw => Raw,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unhandled hierarchy kind."),
};
/// <summary>The realm segment for an address-space realm.</summary>
/// <param name="realm">The address-space realm carried at the OPC UA sinks.</param>
/// <returns>The level-1 trie segment.</returns>
/// <exception cref="ArgumentOutOfRangeException">The realm is not a defined member.</exception>
public static string SegmentFor(AddressSpaceRealm realm) => realm switch
{
AddressSpaceRealm.Uns => Uns,
AddressSpaceRealm.Raw => Raw,
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unhandled address-space realm."),
};
/// <summary>
/// True when <paramref name="segment"/> is exactly a canonical realm segment. Used by the trie
/// builder to reject a scope path that does not start at a realm — such a path addresses a node
/// the walk cannot reach, so the grant would look planted and never match.
/// </summary>
/// <param name="segment">The candidate segment.</param>
/// <returns><see langword="true"/> when it is a realm segment.</returns>
public static bool IsRealmSegment(string? segment) =>
string.Equals(segment, Raw, StringComparison.Ordinal)
|| string.Equals(segment, Uns, StringComparison.Ordinal);
/// <summary>
/// Canonicalizes an operator-supplied realm token from a <c>Namespace</c>-scoped row's
/// <c>ScopeId</c>. Accepts the short token or the full namespace URI, in any case.
/// </summary>
/// <param name="scopeId">The authored scope id.</param>
/// <returns>The canonical realm segment, or <see langword="null"/> when it names no realm.</returns>
public static string? TryCanonicalize(string? scopeId)
{
if (string.IsNullOrWhiteSpace(scopeId)) return null;
var trimmed = scopeId.Trim();
if (string.Equals(trimmed, Raw, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, V3NodeIds.RawNamespaceUri, StringComparison.OrdinalIgnoreCase))
return Raw;
if (string.Equals(trimmed, Uns, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, V3NodeIds.UnsNamespaceUri, StringComparison.OrdinalIgnoreCase))
return Uns;
return null;
}
}
@@ -3,17 +3,16 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster
/// root down through namespace → UNS levels → tag, OR-ing the
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster root
/// down through the realm and then each segment of the node's path, OR-ing the
/// <see cref="TrieGrant.PermissionFlags"/> granted at each visited level for each of the session's
/// principals. The accumulated bitmask is compared to the permission required by the
/// requested <see cref="Abstractions.OpcUaOperation"/>.
/// principals. The accumulated bitmask is compared to the permission required by the requested
/// <see cref="Abstractions.OpcUaOperation"/>.
/// </summary>
/// <remarks>
/// The walk is pure union (additive grants; no explicit Deny in v2.0):
/// encountering a grant at any level contributes its flags, never revokes them. A grant at
/// the Cluster root therefore cascades to every tag below it; a grant at a deep equipment
/// leaf is visible only on that equipment subtree.
/// The walk is pure union (additive grants; no explicit Deny in v2.0): encountering a grant at any
/// level contributes its flags, never revokes them. A grant at the cluster root therefore cascades to
/// every node below it; a grant at a deep leaf is visible only on that subtree.
/// </remarks>
public sealed class PermissionTrie
{
@@ -27,9 +26,9 @@ public sealed class PermissionTrie
public PermissionTrieNode Root { get; init; } = new();
/// <summary>
/// Walk the trie collecting grants that apply to <paramref name="scope"/> for any of the
/// session's <paramref name="principals"/>. Returns the matched-grant list; the caller
/// OR-s the flag bits to decide whether the requested permission is carried.
/// Walk the trie collecting grants that apply to <paramref name="scope"/> for any of the session's
/// <paramref name="principals"/>. Returns the matched-grant list; the caller OR-s the flag bits to
/// decide whether the requested permission is carried.
/// </summary>
/// <param name="scope">The node scope to match permissions for.</param>
/// <param name="principals">The principal names carried by the session (API-key principals, or the reserved "anonymous").</param>
@@ -44,47 +43,42 @@ public sealed class PermissionTrie
var matches = new List<MatchedGrant>();
// Level 0 — cluster-scoped grants.
CollectAtLevel(Root, NodeAclScopeKind.Cluster, groups, matches);
// Level 0 — cluster-scoped grants apply to every node in the cluster.
CollectAtLevel(Root, groups, matches);
// Level 1 — namespace.
if (scope.NamespaceId is null) return matches;
if (!Root.Children.TryGetValue(scope.NamespaceId, out var ns)) return matches;
CollectAtLevel(ns, NodeAclScopeKind.Namespace, groups, matches);
// Level 1 — the realm. Derived from the scope's kind rather than carried on it, so this can
// never disagree with the spelling the scope-path producer used. SegmentFor throws on an
// undefined kind, which is deliberate: a new realm that nobody taught this walk about must fail
// loudly rather than walk some other realm's subtree and return a confidently wrong verdict.
var realm = NodeAclRealms.SegmentFor(scope.Kind);
if (!Root.Children.TryGetValue(realm, out var node)) return matches;
CollectAtLevel(node, groups, matches);
// Below the namespace every kind walks the Equipment hierarchy — Galaxy is a standard
// Equipment-kind driver, so Galaxy points are ordinary equipment tags keyed by
// EquipmentId/path and resolve through this same walk.
WalkEquipment(ns, scope, groups, matches);
// Levels 2..n — the node's path within the realm, however deep it happens to be. There is no
// per-kind ladder here on purpose: the UNS path (Area/Line/Equipment) and a raw path
// (folder…/Driver/Device/group…/Tag) are both just ordered segments, and raw nesting is
// unbounded, so any fixed ladder would silently truncate deep raw trees.
foreach (var segment in scope.Segments)
{
if (segment is null || !node.Children.TryGetValue(segment, out var next)) return matches;
node = next;
CollectAtLevel(node, groups, matches);
}
return matches;
}
private static void WalkEquipment(PermissionTrieNode ns, NodeScope scope, HashSet<string> groups, List<MatchedGrant> matches)
{
if (scope.UnsAreaId is null) return;
if (!ns.Children.TryGetValue(scope.UnsAreaId, out var area)) return;
CollectAtLevel(area, NodeAclScopeKind.UnsArea, groups, matches);
if (scope.UnsLineId is null) return;
if (!area.Children.TryGetValue(scope.UnsLineId, out var line)) return;
CollectAtLevel(line, NodeAclScopeKind.UnsLine, groups, matches);
if (scope.EquipmentId is null) return;
if (!line.Children.TryGetValue(scope.EquipmentId, out var eq)) return;
CollectAtLevel(eq, NodeAclScopeKind.Equipment, groups, matches);
if (scope.TagId is null) return;
if (!eq.Children.TryGetValue(scope.TagId, out var tag)) return;
CollectAtLevel(tag, NodeAclScopeKind.Tag, groups, matches);
}
private static void CollectAtLevel(PermissionTrieNode node, NodeAclScopeKind level, HashSet<string> groups, List<MatchedGrant> matches)
private static void CollectAtLevel(PermissionTrieNode node, HashSet<string> groups, List<MatchedGrant> matches)
{
foreach (var grant in node.Grants)
{
// Provenance is the granting ROW's own scope kind, recorded when the trie was built — not the
// structural depth the grant happens to sit at. Depth-derived provenance was wrong in two
// directions: it could not name a raw level at all (the depth of "Device" varies with folder
// nesting), and where a producer emitted a path whose length disagreed with the row's kind it
// reported a level the operator never authored.
if (groups.Contains(grant.Principal))
matches.Add(new MatchedGrant(grant.Principal, level, grant.PermissionFlags));
matches.Add(new MatchedGrant(grant.Principal, grant.ScopeKind, grant.PermissionFlags));
}
}
}
@@ -96,12 +90,21 @@ public sealed class PermissionTrieNode
public List<TrieGrant> Grants { get; } = [];
/// <summary>
/// Children keyed by the next level's id — namespace id under cluster; UnsAreaId or
/// folder-segment name under namespace; etc. Comparer is OrdinalIgnoreCase so the walk
/// tolerates case drift between the NodeAcl row and the requested scope.
/// Children keyed by the next level's segment — the realm token under the root, then entity names.
/// </summary>
public Dictionary<string, PermissionTrieNode> Children { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <remarks>
/// ⚠️ The comparer is <b>case-sensitive ordinal</b>, matching <c>RawPaths.Comparer</c> and OPC UA
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> before #520 stage 3, which was a silent
/// widening: raw sibling names are unique under an ordinal comparer, so <c>Speed</c> and
/// <c>speed</c> can both exist under one device, and a grant on either would have matched both.
/// Case-sensitive matching is also the fail-closed direction — a case-drifted ACL row now grants
/// nothing instead of granting something adjacent.
/// </remarks>
public Dictionary<string, PermissionTrieNode> Children { get; } = new(StringComparer.Ordinal);
}
/// <summary>Projection of a <see cref="Configuration.Entities.NodeAcl"/> row into the trie.</summary>
public sealed record TrieGrant(string Principal, NodePermissions PermissionFlags);
/// <param name="Principal">The principal the row grants to.</param>
/// <param name="ScopeKind">The row's authored scope kind, carried for decision provenance.</param>
/// <param name="PermissionFlags">The permission bits the row grants.</param>
public sealed record TrieGrant(string Principal, NodeAclScopeKind ScopeKind, NodePermissions PermissionFlags);
@@ -4,107 +4,116 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Builds a <see cref="PermissionTrie"/> from a set of <see cref="NodeAcl"/> rows anchored
/// in one generation. The trie is keyed on the rows' scope hierarchy — rows with
/// <see cref="NodeAclScopeKind.Cluster"/> land at the trie root, rows with
/// <see cref="NodeAclScopeKind.Tag"/> land at a leaf, etc.
/// Builds a <see cref="PermissionTrie"/> from a set of <see cref="NodeAcl"/> rows anchored in one
/// generation. Rows with <see cref="NodeAclScopeKind.Cluster"/> land at the trie root; every other
/// row is placed at the trie node(s) its <c>ScopeId</c> resolves to.
/// </summary>
/// <remarks>
/// <para>Intended to be called by <see cref="PermissionTrieCache"/> once per published
/// generation; the resulting trie is immutable for the life of the cache entry. Idempotent —
/// two builds from the same rows produce equal tries (grant lists may be in insertion order;
/// evaluators don't depend on order).</para>
///
/// <para>The builder deliberately does not know about the node-row metadata the trie path
/// will be walked with. The caller assembles <see cref="NodeScope"/> values from the live
/// config (UnsArea parent of UnsLine, etc.); this class only honors the <c>ScopeId</c>
/// each row carries.</para>
/// <para>
/// Intended to be called once per published generation; the resulting trie is immutable for the
/// life of the cache entry. Idempotent — two builds from the same rows produce equal tries.
/// </para>
/// <para>
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Before #520 stage 3 a null
/// <c>scopePaths</c> made sub-cluster rows fall back to a direct child of the trie
/// root keyed on <c>ScopeId</c>. That position is unreachable by construction — level 1 of the walk
/// is the realm — so the fallback produced grants that looked placed and could never match, and
/// every test that used it proved nothing about production placement. A row that cannot be
/// resolved is now <b>dropped with a diagnostic</b>, which is the fail-closed direction and is
/// honest about what happened.
/// </para>
/// </remarks>
public static class PermissionTrieBuilder
{
/// <summary>
/// Build a trie for one cluster/generation from the supplied rows. The caller is
/// responsible for pre-filtering rows to the target generation + cluster.
/// Build a trie for one cluster/generation from the supplied rows. The caller is responsible for
/// pre-filtering rows to the target generation + cluster.
/// </summary>
/// <param name="clusterId">Cluster the trie is being built for; rows for other clusters are skipped.</param>
/// <param name="generationId">Config-generation the rows belong to; stamped on the returned trie.</param>
/// <param name="rows">ACL rows for this cluster + generation.</param>
/// <param name="scopePaths">
/// Optional <c>ScopeId</c> → multi-level trie-path lookup. When supplied, sub-cluster rows
/// descend to their structurally-correct trie node. When null, sub-cluster rows fall back
/// to a direct child of the trie root keyed on <c>ScopeId</c> — deterministic-test mode.
/// <c>(ScopeKind, ScopeId)</c> → the trie path(s) that scope resolves to, as produced by
/// <see cref="AclScopePathResolver" />. A scope resolves to <b>several</b> paths when one entity is
/// reachable at several places in the address space — a raw tag projected into N equipments by
/// <c>UnsTagReference</c> rows has one raw path plus N UNS paths, and a grant on it must cover all
/// of them or it would be bypassable through the NodeId it missed.
/// </param>
/// <param name="diagnostic">
/// Optional callback invoked when a sub-cluster row's <c>ScopeId</c> cannot be located
/// in <paramref name="scopePaths"/>. Production callers should wire a logger here so
/// orphaned grants surface — silently dropping them under the wrong trie level was a
/// production hazard. The callback fires only when <paramref name="scopePaths"/>
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
/// Optional callback invoked for every row that could not be placed. Production callers should wire
/// a logger here — a dropped grant is invisible otherwise.
/// </param>
/// <returns>The built <see cref="PermissionTrie"/> for the cluster + generation.</returns>
public static PermissionTrie Build(
string clusterId,
long generationId,
IReadOnlyList<NodeAcl> rows,
IReadOnlyDictionary<string, NodeAclPath>? scopePaths = null,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
Action<PermissionTrieBuildDiagnostic>? diagnostic = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(scopePaths);
var trie = new PermissionTrie { ClusterId = clusterId, GenerationId = generationId };
foreach (var row in rows)
{
if (!string.Equals(row.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
var grant = new TrieGrant(row.Principal, row.PermissionFlags);
var node = row.ScopeKind switch
var grant = new TrieGrant(row.Principal, row.ScopeKind, row.PermissionFlags);
if (row.ScopeKind == NodeAclScopeKind.Cluster)
{
NodeAclScopeKind.Cluster => trie.Root,
_ => Descend(trie.Root, row, scopePaths, diagnostic),
};
trie.Root.Grants.Add(grant);
continue;
}
if (node is not null)
// A sub-cluster row with no ScopeId names nothing. It used to vanish here with no signal at
// all — unlike an unresolvable ScopeId, which at least emitted a diagnostic. Both are
// authoring mistakes and both now say so.
if (string.IsNullOrEmpty(row.ScopeId))
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
row.NodeAclId, row.ScopeKind, ScopeId: null,
PermissionTrieBuildDiagnosticReason.MissingScopeId));
continue;
}
if (!scopePaths.TryGetValue(new AclScopeRef(row.ScopeKind, row.ScopeId), out var paths)
|| paths.Count == 0)
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
row.NodeAclId, row.ScopeKind, row.ScopeId,
PermissionTrieBuildDiagnosticReason.UnresolvedScope));
continue;
}
foreach (var path in paths)
{
// Guard the producer's output contract. Level 1 of the walk is always a realm token, so a
// path that is empty or does not start with one addresses a node the walker can never
// reach — the grant would look planted and could never match. That is the same silent
// failure the deleted root-child fallback used to produce; without this check it simply
// moves into the resolver, where nothing else cross-checks it.
if (path.Segments.Count == 0 || !NodeAclRealms.IsRealmSegment(path.Segments[0]))
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
row.NodeAclId, row.ScopeKind, row.ScopeId,
PermissionTrieBuildDiagnosticReason.InvalidScopePath));
continue;
}
var node = trie.Root;
foreach (var segment in path.Segments)
node = EnsureChild(node, segment);
node.Grants.Add(grant);
}
}
return trie;
}
private static PermissionTrieNode? Descend(
PermissionTrieNode root,
NodeAcl row,
IReadOnlyDictionary<string, NodeAclPath>? scopePaths,
Action<PermissionTrieBuildDiagnostic>? diagnostic)
{
if (string.IsNullOrEmpty(row.ScopeId)) return null;
// For sub-cluster scopes the caller supplies a path lookup so we know the containing
// namespace / UnsArea / UnsLine ids. Without a path lookup we fall back to putting the
// row directly under the root using its ScopeId — works for deterministic tests, not
// for production where the hierarchy must be honored. If a scopePaths lookup IS
// provided but is missing the row's ScopeId, surface a diagnostic so the caller can
// log the orphan instead of silently dropping the grant under an unreachable node.
if (scopePaths is null || !scopePaths.TryGetValue(row.ScopeId, out var path))
{
if (scopePaths is not null)
{
diagnostic?.Invoke(new PermissionTrieBuildDiagnostic(
NodeAclId: row.NodeAclId,
ScopeKind: row.ScopeKind,
ScopeId: row.ScopeId,
Reason: PermissionTrieBuildDiagnosticReason.MissingScopePath));
}
return EnsureChild(root, row.ScopeId);
}
var node = root;
foreach (var segment in path.Segments)
node = EnsureChild(node, segment);
return node;
}
private static PermissionTrieNode EnsureChild(PermissionTrieNode parent, string key)
{
if (!parent.Children.TryGetValue(key, out var child))
@@ -117,37 +126,41 @@ public static class PermissionTrieBuilder
}
/// <summary>
/// Ordered list of trie-path segments from root to the target node. Supplied to
/// <see cref="PermissionTrieBuilder.Build"/> so the builder knows where a
/// <see cref="NodeAclScopeKind.UnsLine"/>-scoped row sits in the hierarchy.
/// Ordered list of trie-path segments from the cluster root to the target node, <b>including</b> the
/// realm segment at index 0 (see <see cref="NodeAclRealms"/>).
/// </summary>
/// <param name="Segments">
/// Namespace id, then UnsAreaId / UnsLineId / EquipmentId / TagId as applicable.
/// </param>
/// <param name="Segments">Realm token, then the entity names that lead to the scoped node.</param>
public sealed record NodeAclPath(IReadOnlyList<string> Segments);
/// <summary>
/// Diagnostic emitted by <see cref="PermissionTrieBuilder.Build"/> when a row could not be
/// placed at its structurally-correct trie node. Production callers should log these so
/// orphaned grants surface instead of being silently dropped under an unreachable node.
/// Diagnostic emitted by <see cref="PermissionTrieBuilder.Build"/> for a row that could not be placed.
/// Production callers should log these so dropped grants surface to an operator.
/// </summary>
/// <param name="NodeAclId">The offending row's logical id.</param>
/// <param name="ScopeKind">The row's <see cref="NodeAclScopeKind"/>.</param>
/// <param name="ScopeId">The row's <c>ScopeId</c> that could not be located.</param>
/// <param name="ScopeId">The row's <c>ScopeId</c>, or <see langword="null"/> when it had none.</param>
/// <param name="Reason">Why the diagnostic fired.</param>
public sealed record PermissionTrieBuildDiagnostic(
string NodeAclId,
NodeAclScopeKind ScopeKind,
string ScopeId,
string? ScopeId,
PermissionTrieBuildDiagnosticReason Reason);
/// <summary>Reasons <see cref="PermissionTrieBuildDiagnostic"/> can be emitted.</summary>
/// <summary>Reasons <see cref="PermissionTrieBuildDiagnostic"/> can be emitted. The grant is dropped in every case.</summary>
public enum PermissionTrieBuildDiagnosticReason
{
/// <summary>
/// The row's <c>ScopeId</c> was not present in the supplied <c>scopePaths</c> lookup.
/// The grant is placed as a direct child of the trie root keyed on <c>ScopeId</c> — a
/// position the production trie walker cannot reach for multi-level scopes.
/// The row's <c>ScopeId</c> did not resolve to any node in the deployed address space — a dangling
/// id, a deleted entity, or a scope kind with no v3 meaning (<see cref="NodeAclScopeKind.FolderSegment"/>).
/// </summary>
MissingScopePath,
UnresolvedScope,
/// <summary>The row is narrower than <see cref="NodeAclScopeKind.Cluster"/> but carries no <c>ScopeId</c>.</summary>
MissingScopeId,
/// <summary>
/// A resolved path was empty or did not begin with a realm segment, so it addresses a trie node
/// the walk cannot reach. This is a bug in the scope-path producer, not an authoring mistake.
/// </summary>
InvalidScopePath,
}
@@ -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_&lt;keyId&gt;_&lt;secret&gt;</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_&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
{
@@ -61,10 +61,20 @@ public sealed class ArtifactApiKeyStore : IApiKeyStore
Task.FromResult(ClusterGrants(_authority.Current, principal));
/// <summary>
/// Narrows a principal's grants to the Cluster-scoped ones, matching <c>ConfigDbApiKeyStore</c>.
/// Only those confer an interim role (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens
/// both stores to the full grant set once scope is actually enforced.
/// Narrows a principal's grants to the Cluster-scoped ones. Only those confer an interim role
/// (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens both stores to the full grant set
/// once scope is actually enforced.
/// </summary>
/// <remarks>
/// <b>Scope kind is the ONLY narrowing needed here — cluster confinement already happened.</b>
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> kept only the node's own cluster's ACL rows
/// (<c>ClusterFilterMode.ScopeTo</c>, matched <see cref="StringComparison.OrdinalIgnoreCase"/>),
/// so this snapshot cannot contain another cluster's grant. <c>ConfigDbApiKeyStore</c> reads the
/// fleet-wide ConfigDb and therefore has to apply that confinement itself; the two stores agree on
/// the outcome, not on the mechanism. An earlier version of this comment claimed
/// <c>ConfigDbApiKeyStore</c> "filters the same way in SQL" — it did not filter on cluster at all,
/// which was the cross-cluster over-grant this remark now exists to keep straight.
/// </remarks>
private static IReadOnlyList<NodeAcl> ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal)
{
var all = snapshot.GrantsFor(principal);
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -16,6 +17,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// Reading per request is what makes revocation immediate here, which is why this store is
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
/// </para>
/// <para>
/// ⚠️ <b>The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's.</b> That is
/// the difference from <see cref="ArtifactApiKeyStore"/>, whose snapshot was already narrowed to
/// the node's own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>. So this store must
/// confine grants to <see cref="ClusterId"/> itself — without that predicate a Cluster-scoped
/// grant authored in ANY cluster in the fleet conferred an interim server-wide role here.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStore : IApiKeyStore
{
@@ -23,7 +31,57 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
/// <param name="factory">The config DB context factory.</param>
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory) => _factory = factory;
/// <param name="clusterId">
/// This node's own ClusterId, from <see cref="ResolveClusterId"/>. <see langword="null"/> on a
/// legacy / single-cluster node that carries no <c>cluster-{ClusterId}</c> role — see
/// <see cref="ClusterId"/> for what that means for filtering.
/// </param>
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory, string? clusterId)
{
ArgumentNullException.ThrowIfNull(factory);
_factory = factory;
ClusterId = string.IsNullOrWhiteSpace(clusterId) ? null : clusterId;
}
/// <summary>
/// The node's own ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.
/// </summary>
/// <remarks>
/// A <see langword="null"/> ClusterId applies NO cluster filter, mirroring the artifact path's
/// <c>ClusterFilterMode.None</c> — a single-cluster artifact is applied whole. Failing closed here
/// instead would silently strip every grant on an existing single-cluster deployment, which is a
/// worse failure than the one being fixed and would not be visible until a client was denied.
/// </remarks>
public string? ClusterId { get; }
/// <summary>
/// Resolves the node's ClusterId from its OWN configured roles, e.g. <c>cluster-SITE-A</c> →
/// <c>SITE-A</c>; <see langword="null"/> when no role is cluster-scoped.
/// </summary>
/// <remarks>
/// <para>
/// This is the same derivation <see cref="Cluster.ClusterRoleInfo"/> performs for
/// <c>IClusterRoleInfo.ClusterId</c>, reusing the same <see cref="RoleParser"/> primitives —
/// first cluster-scoped role wins, as it does there.
/// </para>
/// <para>
/// ⚠️ <b>Takes <see cref="AkkaClusterOptions"/> (pure configuration), NOT
/// <c>IClusterRoleInfo</c> resolved from the service provider.</b> <c>ClusterRoleInfo</c>
/// depends on the <c>ActorSystem</c>; resolving it during host composition recurses into
/// ActorSystem construction and stack-overflows the node at boot while compiling clean (see
/// CLAUDE.md, per-cluster mesh Phase 6). The ClusterId is configuration, so read it from
/// configuration.
/// </para>
/// </remarks>
/// <param name="options">The node's bound cluster options.</param>
/// <returns>The node's ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.</returns>
public static string? ResolveClusterId(AkkaClusterOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var clusterRole = options.Roles.FirstOrDefault(RoleParser.IsClusterRole);
return clusterRole is null ? null : RoleParser.ClusterIdFromRole(clusterRole);
}
/// <inheritdoc />
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
@@ -53,15 +111,33 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
}
private static async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
private async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
OtOpcUaConfigDbContext db, string principal, CancellationToken ct)
{
// Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
// those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
return await db.NodeAcls
var rows = await db.NodeAcls
.AsNoTracking()
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
.ToListAsync(ct)
.ConfigureAwait(false);
if (ClusterId is null) return rows;
// The ClusterId match is done HERE rather than as a SQL predicate, unlike the Enabled/expiry
// filters above, because it must be case-insensitive DETERMINISTICALLY. `a.ClusterId == ...` in
// SQL is decided by the database collation — case-insensitive on the usual SQL Server default,
// case-SENSITIVE on a CS collation or on the in-memory provider the tests use — and the two
// spellings genuinely differ: `Cluster:Roles` binds a role verbatim (cluster-SITE-A ⇒ SITE-A)
// while the OTOPCUA_ROLES path goes through RoleParser.Parse, which lower-cases it (⇒ site-a).
// A collation-dependent match would therefore strip every grant on a node configured the second
// way. OrdinalIgnoreCase is the SAME comparison DeploymentArtifact.ParseDataPlaneAuthority uses
// to scope the artifact's grants, so both stores confine identically.
var mine = new List<NodeAcl>(rows.Count);
foreach (var grant in rows)
{
if (string.Equals(grant.ClusterId, ClusterId, StringComparison.OrdinalIgnoreCase)) mine.Add(grant);
}
return mine;
}
}
+9 -1
View File
@@ -376,7 +376,15 @@ if (hasDriver)
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
// on the very next request rather than on the next deployment. Preferred wherever it is available,
// including on a fused admin+driver node.
builder.Services.AddSingleton<IApiKeyStore, ConfigDbApiKeyStore>();
// The ClusterId confines the grants this node honours to its OWN cluster — the ConfigDb holds
// the whole fleet's ACL rows, unlike the artifact a driver-only node reads, which
// ParseDataPlaneAuthority already scoped. Taken from AkkaClusterOptions (pure configuration),
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
// rule is what keeps the redundancy-singleton registration below from recursing into ActorSystem
// construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter (legacy behaviour).
builder.Services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
@@ -4,6 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -46,13 +47,22 @@ public enum ClusterFilterMode
/// lookup filters those, so that a revocation is visible rather than indistinguishable from a
/// missing key.</param>
/// <param name="Grants">The <c>NodeAcl</c> rows scoped to <paramref name="ClusterId"/>.</param>
/// <param name="ScopePaths">
/// Where each grant's scope sits in the address space, resolved from the artifact's own topology.
/// This is why the node needs no ConfigDb to build a permission trie: the artifact already carries
/// every raw and UNS parent link, so the paths are derivable from the same bytes the grants came in.
/// A scope that resolves to nothing is absent here, and the trie builder drops its grant with a
/// diagnostic rather than planting it somewhere unreachable.
/// </param>
public sealed record DataPlaneAuthorityProjection(
string? ClusterId,
IReadOnlyList<ApiKey> Keys,
IReadOnlyList<NodeAcl> Grants)
IReadOnlyList<NodeAcl> Grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths)
{
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty = new(null, [], []);
public static readonly DataPlaneAuthorityProjection Empty =
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
}
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
@@ -610,7 +620,8 @@ public static class DeploymentArtifact
grants.Add(acl);
}
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants);
return new DataPlaneAuthorityProjection(
scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
}
catch (JsonException)
{
@@ -618,6 +629,109 @@ public static class DeploymentArtifact
}
}
/// <summary>
/// Resolve every scope named by <paramref name="grants"/> against the artifact's own raw + UNS
/// topology, using the same <c>RawPathResolver</c> the address space is built through — so an ACL
/// path and the RawPath that forms a node's NodeId cannot drift apart.
/// </summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> BuildAclScopePaths(
JsonElement root,
IReadOnlyList<NodeAcl> grants)
{
if (grants.Count == 0) return new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "RawFolders"))
{
var id = ReadString(el, "RawFolderId");
if (id is null) continue;
folders[id] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id);
}
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "DriverInstances"))
{
var id = ReadString(el, "DriverInstanceId");
if (id is null) continue;
drivers[id] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id);
}
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Devices"))
{
var id = ReadString(el, "DeviceId");
var driverId = ReadString(el, "DriverInstanceId");
if (id is null || driverId is null) continue;
devices[id] = (driverId, ReadString(el, "Name") ?? id);
}
var groups = new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "TagGroups"))
{
var id = ReadString(el, "TagGroupId");
var deviceId = ReadString(el, "DeviceId");
if (id is null || deviceId is null) continue;
groups[id] = (ReadNullableString(el, "ParentTagGroupId"), deviceId, ReadString(el, "Name") ?? id);
}
var tags = new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Tags"))
{
var id = ReadString(el, "TagId");
var deviceId = ReadString(el, "DeviceId");
if (id is null || deviceId is null) continue;
tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id);
}
var areas = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "UnsAreas"))
{
var id = ReadString(el, "UnsAreaId");
if (id is null) continue;
areas[id] = ReadString(el, "Name") ?? id;
}
var lines = new Dictionary<string, (string UnsAreaId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "UnsLines"))
{
var id = ReadString(el, "UnsLineId");
var areaId = ReadString(el, "UnsAreaId");
if (id is null || areaId is null) continue;
lines[id] = (areaId, ReadString(el, "Name") ?? id);
}
var equipment = new Dictionary<string, (string UnsLineId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Equipment"))
{
var id = ReadString(el, "EquipmentId");
var lineId = ReadString(el, "UnsLineId");
if (id is null || lineId is null) continue;
equipment[id] = (lineId, ReadString(el, "Name") ?? id);
}
var references = new List<(string EquipmentId, string TagId, string EffectiveName)>();
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
{
var equipmentId = ReadString(el, "EquipmentId");
var tagId = ReadString(el, "TagId");
if (equipmentId is null || tagId is null) continue;
// Effective name = the override when present, else the backing raw tag's own name. This is the
// same rule the address space materializes UNS variables under; a reference whose tag is
// missing from the artifact has no resolvable name and is skipped.
var effective = ReadNullableString(el, "DisplayNameOverride");
if (string.IsNullOrWhiteSpace(effective))
effective = tags.TryGetValue(tagId, out var t) ? t.Name : null;
if (effective is null) continue;
references.Add((equipmentId, tagId, effective));
}
var resolver = new AclScopePathResolver(
folders, drivers, devices, groups, tags, areas, lines, equipment, references);
return resolver.Resolve(grants);
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
/// missing, so a half-written row can never authenticate.</summary>
private static ApiKey? TryReadApiKey(JsonElement el)
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -533,12 +534,29 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants);
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
// unresolvedScopes counts grants narrower than Cluster whose ScopeId named nothing in this
// artifact — a dangling id, or a scope kind with no v3 entity behind it. Those grants will be
// dropped when the trie is built, so surfacing the count here is the operator's only warning
// that a rule they authored is not going to do anything.
var unresolvedScopes = projection.Grants
.Where(g => g.ScopeKind != NodeAclScopeKind.Cluster && !string.IsNullOrEmpty(g.ScopeId))
.Select(g => new AclScopeRef(g.ScopeKind, g.ScopeId!))
.Distinct()
.Count(r => !projection.ScopePaths.ContainsKey(r));
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants})",
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12],
projection.Keys.Count, projection.Grants.Count);
projection.Keys.Count, projection.Grants.Count, projection.ScopePaths.Count);
if (unresolvedScopes > 0)
{
_log.Warning(
"OpcUaPublish: {Unresolved} ACL scope(s) in generation {Generation} name nothing in the deployed address space — grants on them will be DROPPED, not applied. Check the ScopeId on those rules",
unresolvedScopes, snapshot.GenerationId);
}
}
catch (Exception ex)
{
@@ -0,0 +1,419 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// The #520 stage-3 deploy gate for <see cref="NodeAcl"/> scopes. An unresolvable scope is dropped by
/// the permission-trie builder and the deploy still seals green, so the row reads as a live grant in the
/// AdminUI while authorizing nothing — these pin that it fails the deploy instead.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorNodeAclScopeTests
{
/// <summary>The cluster every ACL row under test is authored in.</summary>
private const string Own = "c";
/// <summary>A second, fully-populated cluster in the same snapshot — the cross-cluster counterparty.</summary>
private const string Other = "other";
private const string OwnEquipId = "EQ-000000000000";
private const string OtherEquipId = "EQ-0000000b0000";
private static NodeAcl Acl(NodeAclScopeKind kind, string? scopeId, string id = "acl-1") => new()
{
NodeAclId = id,
ClusterId = Own,
Principal = "svc-line3",
ScopeKind = kind,
ScopeId = scopeId,
PermissionFlags = NodePermissions.Read,
};
/// <summary>
/// A draft carrying one of each scoped entity in <see cref="Own"/> and a mirrored set in
/// <see cref="Other"/>. Two clusters, not one, so that a cross-cluster id is genuinely present in the
/// snapshot: if the rule's lookups were cluster-filtered such an id would present as merely
/// unresolved and the containment check could never fire.
/// </summary>
private static DraftSnapshot Draft(params NodeAcl[] acls) => new()
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = acls,
RawFolders =
[
new RawFolder { RawFolderId = "rf-1", ClusterId = Own, Name = "plant" },
new RawFolder { RawFolderId = "rf-2", ClusterId = Other, Name = "plant2" },
],
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "di-1", ClusterId = Own, Name = "modbus",
DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-1",
},
new DriverInstance
{
DriverInstanceId = "di-2", ClusterId = Other, Name = "modbus2",
DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-2",
},
],
Devices =
[
new Device { DeviceId = "dev-1", DriverInstanceId = "di-1", Name = "dev1", DeviceConfig = "{}" },
new Device { DeviceId = "dev-2", DriverInstanceId = "di-2", Name = "dev2", DeviceConfig = "{}" },
],
TagGroups =
[
new TagGroup { TagGroupId = "tg-1", DeviceId = "dev-1", Name = "group1" },
new TagGroup { TagGroupId = "tg-2", DeviceId = "dev-2", Name = "group2" },
],
UnsAreas =
[
new UnsArea { UnsAreaId = "area-1", ClusterId = Own, Name = "north" },
new UnsArea { UnsAreaId = "area-2", ClusterId = Other, Name = "south" },
],
UnsLines =
[
new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line3" },
new UnsLine { UnsLineId = "line-2", UnsAreaId = "area-2", Name = "line4" },
],
Equipment =
[
new Equipment
{
EquipmentUuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa"),
EquipmentId = OwnEquipId,
Name = "filler", UnsLineId = "line-1", MachineCode = "m",
},
new Equipment
{
EquipmentUuid = Guid.Parse("0000000b-0000-0000-0000-000000000000"),
EquipmentId = OtherEquipId,
Name = "packer", UnsLineId = "line-2", MachineCode = "m",
},
],
Tags =
[
new Tag
{
TagId = "tag-1", DeviceId = "dev-1", Name = "speed",
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
},
new Tag
{
TagId = "tag-2", DeviceId = "dev-2", Name = "speed2",
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
},
],
};
/// <summary>
/// The fixture itself must trip no rule, or a test asserting on the whole error list is really
/// asserting on unrelated noise. This also pins both hardcoded EquipmentIds against the canonical
/// derivation, which a later edit to the fixture's UUIDs would otherwise silently break.
/// </summary>
[Fact]
public void The_fixture_with_no_acl_rows_is_clean()
{
DraftValidator.Validate(Draft()).ShouldBeEmpty();
DraftValidator.DeriveEquipmentId(Guid.Parse("00000000-0000-0000-0000-0000000000aa")).ShouldBe(OwnEquipId);
DraftValidator.DeriveEquipmentId(Guid.Parse("0000000b-0000-0000-0000-000000000000")).ShouldBe(OtherEquipId);
}
/// <summary>
/// The row shape the trie builder drops without a word: a narrower kind with nothing to narrow to.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_non_Cluster_kind_without_a_ScopeId_is_a_deploy_error(string? scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Equipment, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclScopeIdMissing" && e.Context == "acl-1");
}
/// <summary>
/// A Cluster grant ignores ScopeId, so a populated one means the author picked the wrong kind and the
/// grant is <em>broader</em> than intended — the direction that must never pass silently.
/// </summary>
[Fact]
public void A_Cluster_kind_carrying_a_ScopeId_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, "EQ-000000000000")));
errors.ShouldContain(e => e.Code == "NodeAclScopeIdUnexpected" && e.Context == "acl-1");
}
[Fact]
public void A_Cluster_kind_with_no_ScopeId_passes()
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, null)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// <summary>
/// One case per scoped kind: a well-formed id that names no such entity must fail. Kept exhaustive
/// because the kind→collection mapping is hand-written, and a kind wired to the wrong collection
/// would still "work" for every id that happens to exist in both.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.UnsArea)]
[InlineData(NodeAclScopeKind.UnsLine)]
[InlineData(NodeAclScopeKind.Equipment)]
[InlineData(NodeAclScopeKind.Tag)]
[InlineData(NodeAclScopeKind.RawFolder)]
[InlineData(NodeAclScopeKind.RawDriver)]
[InlineData(NodeAclScopeKind.RawDevice)]
[InlineData(NodeAclScopeKind.RawTagGroup)]
public void A_ScopeId_naming_no_such_entity_is_a_deploy_error(NodeAclScopeKind kind)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, "does-not-exist")));
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
}
/// <summary>
/// The counterpart to the theory above: each kind pointed at the id from its OWN collection must
/// pass. Without this the rule could reject everything and still look correct.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-1")]
[InlineData(NodeAclScopeKind.UnsLine, "line-1")]
[InlineData(NodeAclScopeKind.Equipment, OwnEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-1")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-1")]
[InlineData(NodeAclScopeKind.RawDriver, "di-1")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-1")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-1")]
public void A_ScopeId_naming_a_real_entity_of_its_own_kind_passes(NodeAclScopeKind kind, string scopeId)
{
DraftValidator.Validate(Draft(Acl(kind, scopeId)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// <summary>
/// The containment check, one case per kind. Each id is real and resolves to a real trie path — it
/// just belongs to another cluster, so the grant would confer authority across a cluster boundary.
/// Three of these kinds carry ClusterId directly; the other five are only reachable by walking a
/// parent chain, which is why every kind is covered rather than a representative one.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-2")]
[InlineData(NodeAclScopeKind.UnsLine, "line-2")]
[InlineData(NodeAclScopeKind.Equipment, OtherEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-2")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-2")]
[InlineData(NodeAclScopeKind.RawDriver, "di-2")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-2")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")]
public void A_ScopeId_naming_an_entity_in_another_cluster_is_a_deploy_error(
NodeAclScopeKind kind, string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclScopeCrossCluster" && e.Context == "acl-1");
}
/// <summary>
/// The trap this check exists to avoid: if the lookups were cluster-filtered, a cross-cluster id
/// would present as unresolved, the operator would be told to fix a typo in an id that is not a typo,
/// and the containment rule would never fire at all. The two codes must be mutually exclusive.
/// </summary>
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-2")]
[InlineData(NodeAclScopeKind.UnsLine, "line-2")]
[InlineData(NodeAclScopeKind.Equipment, OtherEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-2")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-2")]
[InlineData(NodeAclScopeKind.RawDriver, "di-2")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-2")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")]
public void A_cross_cluster_scope_is_never_also_reported_as_unresolved(
NodeAclScopeKind kind, string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId)));
errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved");
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)).ShouldHaveSingleItem();
}
/// <summary>The error must name both clusters — which one the grant is in and which one owns the
/// entity — because "re-author under the other cluster" and "repoint at the local entity" are different
/// fixes and the operator picks between them from this message alone.</summary>
[Fact]
public void The_cross_cluster_message_names_both_clusters()
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2")))
.First(e => e.Code == "NodeAclScopeCrossCluster");
error.Message.ShouldContain($"'{Own}'");
error.Message.ShouldContain($"'{Other}'");
}
/// <summary>
/// An entity whose parent link dangles belongs to no cluster, so containment is unknowable — and it
/// resolves to no scope path either, which is the unresolved case. It must report that, once, rather
/// than a cross-cluster error built on a null owner.
/// </summary>
[Fact]
public void A_device_whose_driver_is_missing_reports_unresolved_only()
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = [Acl(NodeAclScopeKind.RawDevice, "dev-orphan")],
Devices =
[
new Device
{
DeviceId = "dev-orphan", DriverInstanceId = "di-gone",
Name = "orphan", DeviceConfig = "{}",
},
],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
}
/// <summary>The UNS counterpart, and a two-hop break rather than one: Equipment reaches its cluster
/// through UnsLine and only then UnsArea, so a missing line strands it just as a missing driver strands
/// a device.</summary>
[Fact]
public void An_equipment_whose_line_is_missing_reports_unresolved_only()
{
var uuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa");
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = [Acl(NodeAclScopeKind.Equipment, OwnEquipId)],
Equipment =
[
new Equipment
{
EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = "filler", UnsLineId = "line-gone", MachineCode = "m",
},
],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
}
/// <summary>
/// ScopeId is a bare string with no foreign key, so a real id of the <em>wrong</em> kind is exactly
/// the mistake that would otherwise hand a grant another entity's subtree.
/// </summary>
[Fact]
public void A_real_id_of_the_wrong_kind_is_still_unresolved()
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.UnsArea, "dev-1")));
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
}
/// <summary>Both realm tokens and both full namespace URIs are accepted, in any case — matching
/// <c>NodeAclRealms.TryCanonicalize</c>, which is what actually resolves the row at build time.</summary>
[Theory]
[InlineData("raw")]
[InlineData("uns")]
[InlineData("RAW")]
[InlineData("Uns")]
[InlineData(V3NodeIds.RawNamespaceUri)]
[InlineData(V3NodeIds.UnsNamespaceUri)]
[InlineData(" raw ")]
public void A_Namespace_scope_naming_a_realm_passes(string scopeId)
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// <summary>The server has exactly two namespaces; anything else names none of them.</summary>
[Theory]
[InlineData("ns=2")]
[InlineData("https://zb.com/otopcua/ns")] // the retired v2 single namespace
[InlineData("equipment")]
public void A_Namespace_scope_naming_no_realm_is_a_deploy_error(string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclNamespaceInvalid" && e.Context == "acl-1");
}
/// <summary>
/// FolderSegment survives in the enum only so later ordinals do not shift; it has no v3 entity and
/// can never resolve, so the error has to name the replacement rather than just say "unresolved".
/// </summary>
[Fact]
public void The_retired_FolderSegment_kind_is_a_deploy_error_naming_RawFolder()
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, "some-folder")))
.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.ShouldHaveSingleItem();
error.Code.ShouldBe("NodeAclScopeKindRetired");
error.Context.ShouldBe("acl-1");
error.Message.ShouldContain("RawFolder");
}
/// <summary>
/// A FolderSegment row with no ScopeId is both retired and missing an id. The retirement is the
/// actionable fact — the kind can never resolve whatever its ScopeId — so telling the operator to
/// supply one would send them to fix the wrong thing.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void FolderSegment_retirement_wins_over_the_missing_ScopeId_error(string? scopeId)
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, scopeId)))
.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.ShouldHaveSingleItem();
error.Code.ShouldBe("NodeAclScopeKindRetired");
}
/// <summary>
/// An ordinal outside the enum comes from a hand-edited row or a stale schema. Every other check
/// switches on the kind, so it must be rejected up front rather than falling into a default branch.
/// </summary>
[Fact]
public void An_undefined_ScopeKind_ordinal_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(Acl((NodeAclScopeKind)99, "anything")));
errors.ShouldContain(e => e.Code == "NodeAclScopeKindUndefined" && e.Context == "acl-1");
}
/// <summary>Every bad row is reported in one pass — the validator's stated contract, and what lets an
/// operator fix a batch of ACLs in one edit instead of one deploy attempt each.</summary>
[Fact]
public void All_offending_rows_are_reported_in_one_pass()
{
var errors = DraftValidator.Validate(Draft(
Acl(NodeAclScopeKind.Equipment, null, "acl-a"),
Acl(NodeAclScopeKind.Cluster, OwnEquipId, "acl-b"),
Acl(NodeAclScopeKind.RawDevice, "nope", "acl-c"),
Acl(NodeAclScopeKind.RawDevice, "dev-2", "acl-d")));
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.Select(e => e.Context)
.ShouldBe(["acl-a", "acl-b", "acl-c", "acl-d"], ignoreOrder: true);
}
}
@@ -0,0 +1,270 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Coverage for <see cref="AclScopePathResolver"/> — the producer that turns an ACL row's
/// <c>ScopeId</c> into the trie path(s) a grant on it must be planted at (#520 stage 3).
/// </summary>
/// <remarks>
/// The fixture is deliberately a <b>deeply nested</b> raw tree — two nested folders and two nested
/// tag groups — because the shape that broke the previous design is exactly the one a shallow
/// fixture cannot express. A five-level ladder resolves the shallowest possible raw tree correctly
/// and silently truncates this one.
/// </remarks>
public sealed class AclScopePathResolverTests
{
// Plant/Line1 (nested folders) → ModbusDrv → dev1 → Fast/Inner (nested groups) → Speed
private static AclScopePathResolver Fixture() => new(
folders: new Dictionary<string, (string? ParentId, string Name)>
{
["RF-plant"] = (null, "Plant"),
["RF-line1"] = ("RF-plant", "Line1"),
},
drivers: new Dictionary<string, (string? RawFolderId, string Name)>
{
["DI-modbus"] = ("RF-line1", "ModbusDrv"),
["DI-rootlevel"] = (null, "RootDrv"),
},
devices: new Dictionary<string, (string DriverInstanceId, string Name)>
{
["DV-1"] = ("DI-modbus", "dev1"),
},
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>
{
["TG-fast"] = (null, "DV-1", "Fast"),
["TG-inner"] = ("TG-fast", "DV-1", "Inner"),
},
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>
{
["TAG-speed"] = ("DV-1", "TG-inner", "Speed"),
["TAG-bare"] = ("DV-1", null, "Bare"),
},
areas: new Dictionary<string, string> { ["UA-w"] = "West" },
lines: new Dictionary<string, (string UnsAreaId, string Name)> { ["UL-1"] = ("UA-w", "L1") },
equipment: new Dictionary<string, (string UnsLineId, string Name)>
{
["EQ-mixer"] = ("UL-1", "Mixer"),
["EQ-filler"] = ("UL-1", "Filler"),
},
references:
[
("EQ-mixer", "TAG-speed", "MixerSpeed"),
("EQ-filler", "TAG-speed", "FillerSpeed"),
]);
private static IReadOnlyList<string> Single(NodeAclScopeKind kind, string scopeId) =>
Fixture().ResolveOne(kind, scopeId).ShouldHaveSingleItem().Segments;
[Fact]
public void A_nested_raw_folder_resolves_through_its_whole_ancestor_chain()
{
Single(NodeAclScopeKind.RawFolder, "RF-line1").ShouldBe(["raw", "Plant", "Line1"]);
}
[Fact]
public void A_raw_device_resolves_through_folders_and_its_driver()
{
Single(NodeAclScopeKind.RawDevice, "DV-1").ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1"]);
}
[Fact]
public void A_nested_tag_group_resolves_through_its_own_ancestor_chain_too()
{
// Both nesting axes at once — the folder chain AND the group chain. This is the case a
// fixed-depth scope model cannot represent at all: it needs seven segments below the realm.
Single(NodeAclScopeKind.RawTagGroup, "TG-inner")
.ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Inner"]);
}
[Fact]
public void Every_container_path_is_a_true_prefix_of_the_tag_beneath_it()
{
// The invariant the trie walk depends on: a grant on a container only cascades to the tags below
// it if the container's path is a genuine prefix of theirs. If the container paths were composed
// in a different order from the tag path — the obvious way to get this wrong, since the tag path
// comes from RawPathResolver and the container paths are composed here — a container grant would
// land on a sibling branch and silently protect nothing.
var resolver = Fixture();
var tag = resolver.ResolveOne(NodeAclScopeKind.Tag, "TAG-speed")
.First(p => p.Segments[0] == NodeAclRealms.Raw).Segments;
foreach (var (kind, id) in new[]
{
(NodeAclScopeKind.RawFolder, "RF-plant"),
(NodeAclScopeKind.RawFolder, "RF-line1"),
(NodeAclScopeKind.RawDriver, "DI-modbus"),
(NodeAclScopeKind.RawDevice, "DV-1"),
(NodeAclScopeKind.RawTagGroup, "TG-fast"),
(NodeAclScopeKind.RawTagGroup, "TG-inner"),
})
{
var container = resolver.ResolveOne(kind, id).ShouldHaveSingleItem().Segments;
container.Count.ShouldBeLessThan(tag.Count, $"{kind} {id}");
tag.Take(container.Count).ShouldBe(container, $"{kind} {id} is not a prefix of the tag path");
}
}
[Fact]
public void A_tag_resolves_to_its_raw_node_AND_every_UNS_projection_of_it()
{
// A raw tag referenced by two equipments is reachable at three NodeIds, and a write through any
// of them lands on the same driver reference. A grant covering only the raw node would be
// bypassable through either UNS NodeId — so all three must be planted.
var paths = Fixture().ResolveOne(NodeAclScopeKind.Tag, "TAG-speed")
.Select(p => p.Segments)
.ToList();
paths.Count.ShouldBe(3);
paths.ShouldContain(s => s.SequenceEqual(
new[] { "raw", "Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Inner", "Speed" }));
paths.ShouldContain(s => s.SequenceEqual(new[] { "uns", "West", "L1", "Mixer", "MixerSpeed" }));
paths.ShouldContain(s => s.SequenceEqual(new[] { "uns", "West", "L1", "Filler", "FillerSpeed" }));
}
[Fact]
public void A_tag_with_no_UNS_reference_resolves_to_its_raw_node_only()
{
// The positive control for the test above: it proves the three-path result came from the two
// references rather than from the resolver always emitting extra paths.
Single(NodeAclScopeKind.Tag, "TAG-bare")
.ShouldBe(["raw", "Plant", "Line1", "ModbusDrv", "dev1", "Bare"]);
}
[Fact]
public void A_driver_at_the_cluster_root_has_no_folder_segments()
{
Single(NodeAclScopeKind.RawDriver, "DI-rootlevel").ShouldBe(["raw", "RootDrv"]);
}
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "UA-w", new[] { "uns", "West" })]
[InlineData(NodeAclScopeKind.UnsLine, "UL-1", new[] { "uns", "West", "L1" })]
[InlineData(NodeAclScopeKind.Equipment, "EQ-mixer", new[] { "uns", "West", "L1", "Mixer" })]
public void UNS_scopes_resolve_to_their_name_path(NodeAclScopeKind kind, string id, string[] expected)
{
Single(kind, id).ShouldBe(expected);
}
[Theory]
[InlineData("raw", "raw")]
[InlineData("RAW", "raw")]
[InlineData("uns", "uns")]
[InlineData("https://zb.com/otopcua/raw", "raw")]
[InlineData("https://zb.com/otopcua/uns", "uns")]
public void A_namespace_scope_accepts_the_token_or_the_full_uri(string scopeId, string expected)
{
Single(NodeAclScopeKind.Namespace, scopeId).ShouldBe([expected]);
}
[Fact]
public void A_namespace_scope_naming_no_realm_resolves_to_nothing()
{
Fixture().ResolveOne(NodeAclScopeKind.Namespace, "https://zb.com/otopcua/ns").ShouldBeEmpty();
}
[Theory]
[InlineData(NodeAclScopeKind.RawFolder, "RF-nope")]
[InlineData(NodeAclScopeKind.RawDriver, "DI-nope")]
[InlineData(NodeAclScopeKind.RawDevice, "DV-nope")]
[InlineData(NodeAclScopeKind.RawTagGroup, "TG-nope")]
[InlineData(NodeAclScopeKind.Tag, "TAG-nope")]
[InlineData(NodeAclScopeKind.UnsArea, "UA-nope")]
[InlineData(NodeAclScopeKind.UnsLine, "UL-nope")]
[InlineData(NodeAclScopeKind.Equipment, "EQ-nope")]
public void A_dangling_scope_id_resolves_to_nothing(NodeAclScopeKind kind, string scopeId)
{
// Resolving to nothing is what makes the builder DROP the grant with a diagnostic. The dangerous
// alternative — inventing a path — would plant an operator's grant on some other node.
Fixture().ResolveOne(kind, scopeId).ShouldBeEmpty();
}
[Fact]
public void The_retired_FolderSegment_kind_resolves_to_nothing()
{
// It described a v2 hierarchy that v3 deleted. Deliberately unresolvable so a surviving row
// produces a diagnostic naming it, rather than a grant that quietly does nothing.
Fixture().ResolveOne(NodeAclScopeKind.FolderSegment, "RF-line1").ShouldBeEmpty();
}
[Fact]
public void A_corrupt_parent_cycle_terminates_instead_of_hanging()
{
// Folder nesting is unbounded by design, so the walk cannot be bounded by a schema limit — only
// by a cycle guard. Without one a corrupt artifact would hang the deploy-apply thread rather than
// dropping one grant.
var resolver = new AclScopePathResolver(
folders: new Dictionary<string, (string? ParentId, string Name)>
{
["A"] = ("B", "A"),
["B"] = ("A", "B"),
},
drivers: new Dictionary<string, (string? RawFolderId, string Name)>(),
devices: new Dictionary<string, (string DriverInstanceId, string Name)>(),
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
areas: new Dictionary<string, string>(),
lines: new Dictionary<string, (string UnsAreaId, string Name)>(),
equipment: new Dictionary<string, (string UnsLineId, string Name)>(),
references: []);
resolver.ResolveOne(NodeAclScopeKind.RawFolder, "A").ShouldBeEmpty();
}
[Fact]
public void Resolve_keys_on_the_kind_as_well_as_the_id()
{
// ScopeId is a bare string with no FK and no type discriminator; ids are prefixed only by
// convention. Keying the lookup on the id alone would let one entity's id collide with another's
// and hand a grant the wrong node's paths — a silent wrong-node authorization.
var resolver = new AclScopePathResolver(
folders: new Dictionary<string, (string? ParentId, string Name)> { ["dup"] = (null, "AsFolder") },
drivers: new Dictionary<string, (string? RawFolderId, string Name)>(),
devices: new Dictionary<string, (string DriverInstanceId, string Name)>(),
groups: new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
tags: new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
areas: new Dictionary<string, string> { ["dup"] = "AsArea" },
lines: new Dictionary<string, (string UnsAreaId, string Name)>(),
equipment: new Dictionary<string, (string UnsLineId, string Name)>(),
references: []);
var resolved = resolver.Resolve(
[
Acl("acl-1", NodeAclScopeKind.RawFolder, "dup"),
Acl("acl-2", NodeAclScopeKind.UnsArea, "dup"),
]);
resolved[new AclScopeRef(NodeAclScopeKind.RawFolder, "dup")]
.ShouldHaveSingleItem().Segments.ShouldBe(["raw", "AsFolder"]);
resolved[new AclScopeRef(NodeAclScopeKind.UnsArea, "dup")]
.ShouldHaveSingleItem().Segments.ShouldBe(["uns", "AsArea"]);
}
[Fact]
public void Resolve_omits_cluster_scoped_and_unresolvable_rows()
{
var resolved = Fixture().Resolve(
[
Acl("acl-cluster", NodeAclScopeKind.Cluster, null),
Acl("acl-dangling", NodeAclScopeKind.Equipment, "EQ-nope"),
Acl("acl-good", NodeAclScopeKind.Equipment, "EQ-mixer"),
]);
resolved.Count.ShouldBe(1);
resolved.ShouldContainKey(new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer"));
}
private static NodeAcl Acl(string id, NodeAclScopeKind kind, string? scopeId) => new()
{
NodeAclId = id,
ClusterId = "c1",
Principal = "p",
ScopeKind = kind,
ScopeId = scopeId,
PermissionFlags = NodePermissions.Read,
};
}
@@ -14,6 +14,14 @@ public sealed class DataPlaneAuthorityStateTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// These tests are about key lookup and the generation counter, not about scope resolution, so
/// they install no scope paths. Resolution is covered by <c>AclScopePathResolverTests</c> and the
/// artifact-to-walk path by <c>DeploymentArtifactScopePathTests</c>.
/// </summary>
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
[Fact]
public void Before_any_artifact_the_node_authenticates_nothing()
{
@@ -30,8 +38,8 @@ public sealed class DataPlaneAuthorityStateTests
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-2", [], []).GenerationId.ShouldBe(2);
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-2", [], [], NoScopes).GenerationId.ShouldBe(2);
}
[Fact]
@@ -41,8 +49,8 @@ public sealed class DataPlaneAuthorityStateTests
// generation there would invalidate every cached authorization decision for no reason.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], [], NoScopes).GenerationId.ShouldBe(1);
}
[Fact]
@@ -56,7 +64,7 @@ public sealed class DataPlaneAuthorityStateTests
var sut = new DataPlaneAuthorityState();
var generations = new[] { "ffff", "cccc", "aaaa", "0001" }
.Select(rev => sut.Apply(null, rev, [], []).GenerationId)
.Select(rev => sut.Apply(null, rev, [], [], NoScopes).GenerationId)
.ToArray();
generations.ShouldBe([1, 2, 3, 4]);
@@ -66,7 +74,7 @@ public sealed class DataPlaneAuthorityStateTests
public void A_usable_key_is_found_by_id()
{
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopes);
sut.Current.FindUsableKey("abc", Now)!.Principal.ShouldBe("p");
}
@@ -77,7 +85,7 @@ public sealed class DataPlaneAuthorityStateTests
// Key ids are minted lowercase hex, but an operator copying one out of the AdminUI can upper-case
// it. A case-sensitive miss would read to them as "the key was revoked".
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("abc", "p")], []);
sut.Apply(null, "rev-1", [Key("abc", "p")], [], NoScopes);
sut.Current.FindUsableKey("ABC", Now).ShouldNotBeNull();
}
@@ -88,7 +96,7 @@ public sealed class DataPlaneAuthorityStateTests
var revoked = Key("abc", "p");
revoked.Enabled = false;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [revoked], []);
sut.Apply(null, "rev-1", [revoked], [], NoScopes);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull();
}
@@ -101,7 +109,7 @@ public sealed class DataPlaneAuthorityStateTests
var expiring = Key("abc", "p");
expiring.ExpiresUtc = Now;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [expiring], []);
sut.Apply(null, "rev-1", [expiring], [], NoScopes);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull("expiry is inclusive — at the instant it expires it is gone");
sut.Current.FindUsableKey("abc", Now.AddSeconds(-1)).ShouldNotBeNull();
@@ -115,7 +123,7 @@ public sealed class DataPlaneAuthorityStateTests
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "bob", NodePermissions.WriteOperate),
]);
], NoScopes);
sut.Current.GrantsFor("alice").ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-1");
sut.Current.GrantsFor("nobody").ShouldBeEmpty();
@@ -129,7 +137,7 @@ public sealed class DataPlaneAuthorityStateTests
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "alice", NodePermissions.WriteOperate),
]);
], NoScopes);
sut.Current.GrantsFor("alice").Count.ShouldBe(2);
}
@@ -140,8 +148,8 @@ public sealed class DataPlaneAuthorityStateTests
// Merging would make revocation impossible: a key dropped from the artifact would live on in the
// node's memory forever.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("old", "p")], []);
sut.Apply(null, "rev-2", [Key("new", "p")], []);
sut.Apply(null, "rev-1", [Key("old", "p")], [], NoScopes);
sut.Apply(null, "rev-2", [Key("new", "p")], [], NoScopes);
sut.Current.FindUsableKey("old", Now).ShouldBeNull();
sut.Current.FindUsableKey("new", Now).ShouldNotBeNull();
@@ -154,7 +162,7 @@ public sealed class DataPlaneAuthorityStateTests
// artifact — where refusing to build the snapshot would leave the node authenticating nothing.
var sut = new DataPlaneAuthorityState();
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], []));
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], [], NoScopes));
sut.Current.FindUsableKey("dup", Now).ShouldNotBeNull();
}
@@ -164,7 +172,7 @@ public sealed class DataPlaneAuthorityStateTests
// The node manager's only source of cluster identity; NodeScope.ClusterId is required and the
// evaluator fails closed when it disagrees with the session's.
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [], []);
sut.Apply("cluster-a", "rev-1", [], [], NoScopes);
sut.Current.ClusterId.ShouldBe("cluster-a");
}
@@ -7,17 +7,19 @@ using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s
/// <c>Descend</c> helper:
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s row placement:
/// <list type="bullet">
/// <item>With a <c>scopePaths</c> lookup the row must land at the correct multi-level
/// trie node — a deep <see cref="NodeAclScopeKind.UnsLine"/> grant must be visible
/// ONLY when the requested scope walks the same namespace/area/line chain.</item>
/// <item>Without a <c>scopePaths</c> entry the row falls back to a direct child of
/// the namespace root keyed on the row's <c>ScopeId</c>. The builder must surface
/// this fallback (warning callback) so callers know a grant was placed where the
/// walker can't reach it for production hierarchies — silently dropping the grant
/// is the Core-011 production hazard.</item>
/// <item>A row whose scope resolves must land at the correct multi-level trie node — a deep
/// <see cref="NodeAclScopeKind.UnsLine"/> grant is visible ONLY when the requested scope
/// walks the same realm/area/line chain.</item>
/// <item>A row whose scope does NOT resolve must be <b>dropped with a diagnostic</b>. Before
/// #520 stage 3 it was planted as a direct child of the trie root keyed on <c>ScopeId</c>,
/// a position level 1 of the walk (the realm) can never reach — so the grant looked placed
/// and could never match. Silently misplacing the grant is the Core-011 production
/// hazard.</item>
/// <item>A scope resolving to several paths (a raw tag projected into N equipments) must plant
/// the grant at <b>all</b> of them, or the grant is bypassable through the NodeId it
/// missed.</item>
/// </list>
/// </summary>
[Trait("Category", "Unit")]
@@ -35,78 +37,150 @@ public sealed class PermissionTrieBuilderTests
PermissionFlags = flags,
};
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
new()
/// <summary>One scope resolving to one path.</summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
entries.ToDictionary(
e => new AclScopeRef(e.Kind, e.ScopeId),
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
[Fact]
public void Build_Path_Not_Starting_At_A_Realm_Is_Rejected_Not_Planted()
{
// Level 1 of the walk is always a realm token, so each of these addresses a node CollectMatches
// can never reach. Without this guard the grant would be planted, look present in the trie, and
// silently never match — the same failure mode as the deleted root-child fallback, just relocated
// into the scope-path producer where nothing else cross-checks it.
string[][] malformed =
[
[], // empty path
["area-1", "line-1"], // producer forgot the realm segment
["Raw", "area-1"], // realm token with the wrong case
["https://zb.com/otopcua/raw", "area-1"], // URI where the short token belongs
];
foreach (var segments in malformed)
{
ClusterId = cluster,
NamespaceId = ns,
UnsAreaId = area,
UnsLineId = line,
EquipmentId = equip,
TagId = tag,
Kind = NodeHierarchyKind.Equipment,
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var rows = new[] { Row("cn=a", NodeAclScopeKind.UnsLine, "line-1", NodePermissions.Read) };
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.UnsLine, "line-1")] = [new NodeAclPath(segments)],
};
var label = segments.Length == 0 ? "(empty)" : string.Join('/', segments);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.ShouldHaveSingleItem(label).Reason
.ShouldBe(PermissionTrieBuildDiagnosticReason.InvalidScopePath, label);
trie.Root.Children.ShouldBeEmpty(label);
}
}
[Fact]
public void Build_Plants_The_Valid_Paths_Of_A_Scope_Even_When_A_Sibling_Path_Is_Malformed()
{
// The positive control for the theory above: a guard that rejected every path would satisfy it.
// A multi-path scope must keep the reachable placements and drop only the broken one.
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var rows = new[] { Row("cn=a", NodeAclScopeKind.Tag, "tag-1", NodePermissions.Read) };
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Tag, "tag-1")] =
[
new NodeAclPath(RawPath("Drv", "dev1", "Speed")),
new NodeAclPath(["area-1", "line-1"]),
],
};
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.ShouldHaveSingleItem().Reason
.ShouldBe(PermissionTrieBuildDiagnosticReason.InvalidScopePath);
trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["Drv", "dev1", "Speed"],
},
["cn=a"]).ShouldHaveSingleItem();
}
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
private static NodeScope UnsScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
private static NodeScope RawScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
/// <summary>Verifies that Build with ScopePaths places UnsLine row at correct multi-level node.</summary>
[Fact]
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
{
// Scope path mirrors the production hierarchy: namespace → area → line.
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-42"] = new(new[] { "ns", "area-1", "line-42" }),
};
// Scope path mirrors the production hierarchy: realm → area → line.
var paths = ScopePaths(
(NodeAclScopeKind.UnsLine, "line-42", UnsPath("area-1", "line-42")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
// Walk through the same chain — the grant must be reachable.
var matchOnLine = trie.CollectMatches(
EquipmentTag("c1", "ns", "area-1", "line-42", "eq-A", "tag-A"),
UnsScope("c1", "area-1", "line-42", "eq-A", "tag-A"),
["cn=ops"]);
matchOnLine.Count.ShouldBe(1, "row must land at the correct multi-level trie node");
// A different line under the same area must not pick up the grant.
var matchOnOtherLine = trie.CollectMatches(
EquipmentTag("c1", "ns", "area-1", "line-99", "eq-A", "tag-A"),
UnsScope("c1", "area-1", "line-99", "eq-A", "tag-A"),
["cn=ops"]);
matchOnOtherLine.ShouldBeEmpty(
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
}
/// <summary>Verifies that Build without ScopePaths falls back to root child for deterministic tests.</summary>
/// <summary>
/// Replaces <c>Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests</c>: the null-scopePaths
/// "deterministic test mode" is deleted. An unresolvable sub-cluster row is now dropped entirely —
/// it must NOT appear as a root child keyed on its <c>ScopeId</c>, because level 1 of the walk is
/// the realm and nothing can ever reach such a node.
/// </summary>
[Fact]
public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
public void Build_Unresolvable_Row_Is_Dropped_Not_Planted_At_Root_Child()
{
// Fallback path — deterministic tests pass without a scope-path lookup. The row
// is placed as a direct child of the trie root keyed by ScopeId.
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build(
"c1", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
// Root has one child — "line-42".
trie.Root.Children.ShouldContainKey("line-42");
var node = trie.Root.Children["line-42"];
node.Grants.Count.ShouldBe(1);
trie.Root.Children.ShouldNotContainKey("line-42",
"the retired fallback planted the row here, where the realm-first walk can never reach it");
trie.Root.Children.ShouldBeEmpty();
trie.Root.Grants.ShouldBeEmpty("an unresolvable row must not become a cluster-wide grant either");
}
/// <summary>
/// Core-011 regression: when a sub-cluster row's ScopeId is not in the supplied
/// <c>scopePaths</c>, the fallback diagnostic callback must fire so the caller can
/// surface a warning. Silently dropping the grant under the wrong trie level is the
/// production hazard the finding flagged.
/// Core-011 regression: when a sub-cluster row's scope is not in the supplied <c>scopePaths</c>,
/// the diagnostic callback must fire so the caller can surface a warning. Silently dropping the
/// grant is the production hazard the finding flagged.
/// </summary>
[Fact]
public void Build_Missing_ScopePath_Entry_Invokes_Diagnostic_Callback()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-known"] = new(new[] { "ns", "area-1", "line-known" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.UnsLine, "line-known", UnsPath("area-1", "line-known")));
// Row references a line that is NOT in the path lookup.
var orphan = Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read);
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read),
orphan,
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-known", NodePermissions.Read),
};
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
@@ -114,45 +188,165 @@ public sealed class PermissionTrieBuilderTests
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.Count.ShouldBe(1, "exactly one row had no matching scope-path entry");
diagnostics[0].NodeAclId.ShouldBe(orphan.NodeAclId);
diagnostics[0].ScopeId.ShouldBe("line-orphan");
diagnostics[0].ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
trie.CollectMatches(UnsScope("c1", "area-1", "line-known", "eq", "tag"), ["cn=ops"])
.Count.ShouldBe(1, "the resolvable row is still placed");
}
/// <summary>Verifies that no diagnostic is emitted when all sub-cluster rows have ScopePaths.</summary>
/// <summary>
/// The scope-path lookup is keyed on <c>(Kind, ScopeId)</c>, not the bare id: an entry filed under
/// one kind must not resolve a row of another kind that happens to share the id. <c>ScopeId</c> is
/// an unconstrained string, so id collisions across entity types are possible and resolving one
/// through the other is a silent wrong-node authorization.
/// </summary>
[Fact]
public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
public void Build_ScopePath_Lookup_Is_Keyed_On_Kind_As_Well_As_Id()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["line-A"] = new(new[] { "ns", "area-1", "line-A" }),
["line-B"] = new(new[] { "ns", "area-1", "line-B" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.UnsLine, "shared-id", UnsPath("area-1", "line-1")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "shared-id", NodePermissions.Read) };
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.Count.ShouldBe(1);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq", "tag"), ["cn=ops"])
.ShouldBeEmpty("an Equipment row must not borrow a UnsLine entry's path");
}
/// <summary>
/// Verifies that a diagnostic fires for the unresolvable row and for that row only. The negative
/// half on its own ("no diagnostics were emitted") passes for a <c>Build</c> that never emits any,
/// so the positive control lives in the same build rather than in a separate test.
/// </summary>
[Fact]
public void Build_Diagnostic_Fires_For_The_Unresolvable_Row_Only()
{
var paths = ScopePaths(
(NodeAclScopeKind.UnsLine, "line-A", UnsPath("area-1", "line-A")),
(NodeAclScopeKind.UnsLine, "line-B", UnsPath("area-1", "line-B")));
var orphan = Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read);
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — no descent
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — never descends
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-A", NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-B", NodePermissions.Read),
orphan, // positive control
};
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
diagnostics.Count.ShouldBe(1, "only the orphan row is unresolvable");
diagnostics[0].NodeAclId.ShouldBe(orphan.NodeAclId,
"the cluster row and the two resolvable line rows must not emit a diagnostic");
// …and the rows that did resolve are genuinely placed, so "one diagnostic" is not being reached
// by dropping everything.
trie.CollectMatches(UnsScope("c1", "area-1", "line-A", "eq", "tag"), ["cn=ops"])
.Count.ShouldBe(2, "the cluster grant plus the line-A grant");
trie.CollectMatches(UnsScope("c1", "area-1", "line-B", "eq", "tag"), ["cn=ops"])
.Count.ShouldBe(2, "the cluster grant plus the line-B grant");
}
/// <summary>Verifies that diagnostic callback is optional when ScopePaths is null.</summary>
[Fact]
public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
/// <summary>
/// Replaces <c>Build_Diagnostic_Callback_Optional_When_ScopePaths_Null</c> — <c>scopePaths</c> is
/// required now, so the mode that test described no longer exists. What still needs pinning is the
/// other authoring mistake: a sub-cluster row carrying no <c>ScopeId</c> names nothing, and used to
/// vanish with no signal at all.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
public void Build_Sub_Cluster_Row_Without_ScopeId_Emits_MissingScopeId(string? scopeId)
{
// No diagnostics callback should fire when scopePaths itself is null — that's the
// "deterministic-test fallback" mode, not a production drop.
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
var row = Row("cn=ops", NodeAclScopeKind.UnsLine, scopeId, NodePermissions.Read);
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
PermissionTrieBuilder.Build("c1", 1, rows, scopePaths: null, diagnostic: diagnostics.Add);
var trie = PermissionTrieBuilder.Build(
"c1", 1, [row], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
diagnostics.ShouldBeEmpty(
"scopePaths=null is the explicit test-fallback mode and must not emit per-row warnings");
diagnostics.Count.ShouldBe(1);
diagnostics[0].NodeAclId.ShouldBe(row.NodeAclId);
diagnostics[0].ScopeId.ShouldBeNull("the row had no scope id to report");
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopeId);
trie.Root.Grants.ShouldBeEmpty("a scope-less sub-cluster row must not fall through to the cluster root");
}
/// <summary>
/// A <see cref="NodeAclScopeKind.Cluster"/> row never consults <c>scopePaths</c> and never emits a
/// diagnostic, even though it carries a null <c>ScopeId</c> — that is the one kind for which a null
/// id is correct.
/// </summary>
[Fact]
public void Build_Cluster_Row_With_Null_ScopeId_Is_Placed_Without_Diagnostic()
{
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build(
"c1", 1, rows, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), diagnostics.Add);
diagnostics.ShouldBeEmpty();
trie.Root.Grants.Count.ShouldBe(1);
}
/// <summary>
/// A scope that resolves to several paths — a raw tag plus its UNS projections — plants the grant
/// at every one of them. A write routes to the same driver reference through either NodeId, so a
/// grant covering only one path would be bypassable through the other.
/// </summary>
[Fact]
public void Build_Multi_Path_Scope_Plants_The_Grant_At_Every_Path()
{
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Tag, "tag-1")] = new[]
{
new NodeAclPath(RawPath("Plant", "Modbus", "dev1", "Speed")),
new NodeAclPath(UnsPath("area-1", "line-1", "eq-A", "Speed")),
new NodeAclPath(UnsPath("area-1", "line-1", "eq-B", "MotorSpeed")),
},
};
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Tag, "tag-1", NodePermissions.WriteOperate) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
.Count.ShouldBe(1, "the raw NodeId");
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-A", "Speed"), ["cn=ops"])
.Count.ShouldBe(1, "the first UNS projection");
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-B", "MotorSpeed"), ["cn=ops"])
.Count.ShouldBe(1, "the second UNS projection, under its own effective name");
trie.CollectMatches(UnsScope("c1", "area-1", "line-1", "eq-B", "Speed"), ["cn=ops"])
.ShouldBeEmpty("eq-B projects the tag as MotorSpeed; the eq-A name must not match there");
}
/// <summary>
/// An empty path list for a resolved key is treated the same as an absent key — dropped with an
/// <see cref="PermissionTrieBuildDiagnosticReason.UnresolvedScope"/> diagnostic rather than
/// silently planting the grant at the cluster root, which is where a zero-segment walk would leave
/// it.
/// </summary>
[Fact]
public void Build_Empty_Path_List_Is_Treated_As_Unresolved()
{
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Equipment, "eq-A")] = Array.Empty<NodeAclPath>(),
};
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
diagnostics.Count.ShouldBe(1);
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
trie.Root.Grants.ShouldBeEmpty("an empty path list must not become a cluster-wide grant");
}
}
@@ -6,9 +6,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// Coverage for <see cref="PermissionTrie.CollectMatches"/> — the walk itself. Post-#520-stage-3 the
/// walk is <c>cluster → realm → …variable-length node path…</c>, so every non-cluster test here
/// supplies a real <c>scopePaths</c> entry whose path starts with a realm segment and drives a
/// <see cref="NodeScope.Segments"/> chain through it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PermissionTrieTests
{
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
new()
{
@@ -21,27 +30,36 @@ public sealed class PermissionTrieTests
PermissionFlags = flags,
};
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
new()
{
ClusterId = cluster,
NamespaceId = ns,
UnsAreaId = area,
UnsLineId = line,
EquipmentId = equip,
TagId = tag,
Kind = NodeHierarchyKind.Equipment,
};
/// <summary>One scope resolving to one path.</summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
entries.ToDictionary(
e => new AclScopeRef(e.Kind, e.ScopeId),
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
private static NodeScope UnsScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
private static NodeScope RawScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
/// <summary>Verifies cluster-level grant cascades to every tag.</summary>
[Fact]
public void ClusterLevelGrant_Cascades_ToEveryTag()
{
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, scopeId: null, NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
var matches = trie.CollectMatches(
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
["cn=ops"]);
matches.Count.ShouldBe(1);
@@ -53,38 +71,44 @@ public sealed class PermissionTrieTests
[Fact]
public void EquipmentScope_DoesNotLeak_ToSibling()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["eq-A"] = new(new[] { "ns", "area1", "line1", "eq-A" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.Equipment, "eq-A", UnsPath("area1", "line1", "eq-A")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
var matchA = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
var matchB = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
matchA.Count.ShouldBe(1);
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
}
/// <summary>Verifies multiple groups union their permission flags.</summary>
/// <summary>
/// Verifies that every principal-matching grant is collected once, with its own flags left
/// untouched. <see cref="PermissionTrie.CollectMatches"/> deliberately does NOT combine the flag
/// bits — the OR lives in <c>TriePermissionEvaluator.Authorize</c> and is pinned there by
/// <c>TriePermissionEvaluatorTests.Union_Across_Levels_Ors_Flags_From_Every_Match</c>. (Renamed
/// from <c>MultiGroup_Union_OrsPermissionFlags</c>, which OR-ed the flags in the test body and so
/// asserted nothing about production behaviour.)
/// </summary>
[Fact]
public void MultiGroup_Union_OrsPermissionFlags()
public void MultiGroup_Matches_Are_Collected_PerGrant_Without_Combining()
{
var rows = new[]
{
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
};
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
var matches = trie.CollectMatches(
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
["cn=readers", "cn=writers"]);
matches.Count.ShouldBe(2);
var combined = matches.Aggregate(NodePermissions.None, (acc, m) => acc | m.PermissionFlags);
combined.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
matches.Select(m => m.Principal).ShouldBe(["cn=readers", "cn=writers"], ignoreOrder: true);
matches.Single(m => m.Principal == "cn=readers").PermissionFlags.ShouldBe(NodePermissions.Read);
matches.Single(m => m.Principal == "cn=writers").PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
}
/// <summary>Verifies no matching group returns empty.</summary>
@@ -92,57 +116,52 @@ public sealed class PermissionTrieTests
public void NoMatchingGroup_ReturnsEmpty()
{
var rows = new[] { Row("cn=different", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
var matches = trie.CollectMatches(
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
["cn=ops"]);
matches.ShouldBeEmpty();
}
/// <summary>
/// Galaxy is now a standard Equipment-kind driver: Galaxy points are ordinary equipment
/// tags, so a grant on a Galaxy equipment resolves via the Equipment walk and must not leak
/// to a sibling Galaxy equipment.
/// Galaxy is a standard Equipment-kind driver: Galaxy points are ordinary equipment tags, so a
/// grant on a Galaxy equipment resolves via the UNS walk and must not leak to a sibling Galaxy
/// equipment.
/// </summary>
[Fact]
public void Galaxy_EquipmentScope_Grant_DoesNotLeak_To_Sibling_Equipment()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["gal-eq-A"] = new(new[] { "ns-gal", "area1", "line1", "gal-eq-A" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.Equipment, "gal-eq-A", UnsPath("area1", "line1", "gal-eq-A")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq-A", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-A", "tag1"), ["cn=ops"]);
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-B", "tag1"), ["cn=ops"]);
var matchA = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq-A", "tag1"), ["cn=ops"]);
var matchB = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq-B", "tag1"), ["cn=ops"]);
matchA.Count.ShouldBe(1);
matchB.ShouldBeEmpty("grant at gal-eq-A must not apply to sibling gal-eq-B");
}
/// <summary>
/// A grant on a Galaxy equipment tag is reported with its true Equipment-walk structural
/// scope (Equipment), not a Galaxy-specific scope — Galaxy now flows through the same
/// Equipment permission walk as every other equipment tag.
/// A grant on a Galaxy equipment tag is reported with the authored row's Equipment scope kind —
/// Galaxy flows through the same UNS permission walk as every other equipment tag.
/// </summary>
[Fact]
public void Galaxy_EquipmentScope_Grant_Reports_Equipment_Scope()
{
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
{
["gal-eq"] = new(new[] { "ns-gal", "area1", "line1", "gal-eq" }),
};
var paths = ScopePaths(
(NodeAclScopeKind.Equipment, "gal-eq", UnsPath("area1", "line1", "gal-eq")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matches = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq", "tag1"), ["cn=ops"]);
var matches = trie.CollectMatches(UnsScope("c1", "area1", "line1", "gal-eq", "tag1"), ["cn=ops"]);
matches.Count.ShouldBe(1);
matches[0].Scope.ShouldBe(NodeAclScopeKind.Equipment,
"Galaxy equipment grants resolve via the Equipment walk and report Equipment scope");
"Galaxy equipment grants resolve via the UNS walk and report the row's Equipment scope");
}
/// <summary>Verifies cross-cluster grant does not leak.</summary>
@@ -150,16 +169,21 @@ public sealed class PermissionTrieTests
public void CrossCluster_Grant_DoesNotLeak()
{
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read, clusterId: "c-other") };
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
var trie = PermissionTrieBuilder.Build("c1", 1, rows, NoScopePaths);
var matches = trie.CollectMatches(
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
UnsScope("c1", "area1", "line1", "eq1", "tag1"),
["cn=ops"]);
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
}
/// <summary>Verifies build is idempotent.</summary>
/// <summary>
/// Verifies build is idempotent, against an <b>independent</b> expectation rather than by
/// comparing one build's output to the other's. The previous form asserted
/// <c>trie1.Count.ShouldBe(trie2.Count)</c>, which a <c>Build</c> that dropped every row passed as
/// <c>0 == 0</c>; the absolute counts below go red the moment placement regresses.
/// </summary>
[Fact]
public void Build_IsIdempotent()
{
@@ -167,13 +191,187 @@ public sealed class PermissionTrieTests
{
Row("cn=a", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
Row("cn=b", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
Row("cn=a", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate),
};
var paths = ScopePaths(
(NodeAclScopeKind.Equipment, "eq-A", UnsPath("area1", "line1", "eq-A")));
var scope = UnsScope("c1", "area1", "line1", "eq-A", "tag1");
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows);
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows);
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows, paths);
trie1.Root.Grants.Count.ShouldBe(trie2.Root.Grants.Count);
trie1.ClusterId.ShouldBe(trie2.ClusterId);
trie1.GenerationId.ShouldBe(trie2.GenerationId);
foreach (var trie in new[] { trie1, trie2 })
{
trie.ClusterId.ShouldBe("c1");
trie.GenerationId.ShouldBe(1);
trie.Root.Grants.Count.ShouldBe(2, "both cluster rows land at the root");
trie.CollectMatches(scope, ["cn=a"]).Count.ShouldBe(2, "one cluster grant + the eq-A grant");
trie.CollectMatches(scope, ["cn=b"]).Count.ShouldBe(1, "cn=b holds only the cluster grant");
}
}
/// <summary>
/// A grant planted at a raw container matches a tag nested arbitrarily deep beneath it — two
/// nested folders and two nested tag groups here — and does <b>not</b> match a sibling device.
/// The sibling leg is the negative control: without it a walk that ignored
/// <see cref="NodeScope.Segments"/> entirely would still pass the positive leg.
/// </summary>
[Fact]
public void RawDevice_Grant_Matches_Deeply_Nested_Tag_And_Not_A_Sibling_Device()
{
var paths = ScopePaths(
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "CellA", "Modbus", "dev1")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
// raw / Plant / CellA / Modbus / dev1 / Motors / Drive / Speed — 7 segments below the realm.
var beneath = trie.CollectMatches(
RawScope("c1", "Plant", "CellA", "Modbus", "dev1", "Motors", "Drive", "Speed"),
["cn=ops"]);
var sibling = trie.CollectMatches(
RawScope("c1", "Plant", "CellA", "Modbus", "dev2", "Motors", "Drive", "Speed"),
["cn=ops"]);
beneath.Count.ShouldBe(1, "a container grant cascades to every node beneath it, at any depth");
beneath[0].Scope.ShouldBe(NodeAclScopeKind.RawDevice);
sibling.ShouldBeEmpty("grant at dev1 must not reach sibling device dev2");
}
/// <summary>
/// Regression guard for the <c>StringComparer.Ordinal</c> children comparer (#520 stage 3): a
/// grant authored on <c>Speed</c> must not match a scope for <c>speed</c>. Raw sibling names are
/// unique only under an ordinal comparer, so both spellings can exist under one device and the
/// previous <c>OrdinalIgnoreCase</c> map silently granted one through the other. The positive leg
/// is asserted alongside so the negative cannot pass by the walk simply never matching.
/// </summary>
[Fact]
public void Segment_Matching_Is_Case_Sensitive()
{
var paths = ScopePaths(
(NodeAclScopeKind.Tag, "tag-speed", RawPath("Plant", "Modbus", "dev1", "Speed")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Tag, "tag-speed", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var exact = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
var caseDrifted = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "speed"), ["cn=ops"]);
exact.Count.ShouldBe(1, "the exact spelling must match, or the negative leg proves nothing");
caseDrifted.ShouldBeEmpty(
"segment matching is case-sensitive ordinal — 'Speed' and 'speed' are distinct raw siblings");
}
/// <summary>
/// Regression guard for the case-sensitive comparer on the <b>container</b> levels too: a grant
/// under device <c>Dev1</c> must not cascade into a scope walking <c>dev1</c>.
/// </summary>
[Fact]
public void Container_Segment_Matching_Is_Case_Sensitive()
{
var paths = ScopePaths(
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "Dev1")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "Dev1", "Speed"), ["cn=ops"])
.Count.ShouldBe(1);
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
.ShouldBeEmpty("a case-drifted container segment must not cascade");
}
/// <summary>
/// The realm is a hard gate at level 1: a grant planted in the raw realm must not match a scope in
/// the UNS realm, even when the segments below the realm are identical. This pins that the walk
/// derives its level-1 key from <see cref="NodeScope.Kind"/> via
/// <see cref="NodeAclRealms.SegmentFor(NodeHierarchyKind)"/> rather than skipping the level.
/// </summary>
[Fact]
public void Grant_In_One_Realm_DoesNot_Match_A_Scope_In_The_Other()
{
var paths = ScopePaths(
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1")));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
.Count.ShouldBe(1, "the raw scope must match, or the cross-realm leg proves nothing");
trie.CollectMatches(UnsScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"])
.ShouldBeEmpty("a raw-realm grant must not be reachable from a UNS scope");
}
/// <summary>
/// A <see cref="NodeAclScopeKind.Namespace"/> row planted at the realm segment itself grants every
/// node in that realm and nothing in the other one.
/// </summary>
[Fact]
public void NamespaceScope_Grant_Covers_Its_Realm_Only()
{
var paths = ScopePaths(
(NodeAclScopeKind.Namespace, NodeAclRealms.Raw, [NodeAclRealms.Raw]));
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Namespace, NodeAclRealms.Raw, NodePermissions.Read) };
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var inRaw = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
var inUns = trie.CollectMatches(UnsScope("c1", "area1", "line1", "eq1", "tag1"), ["cn=ops"]);
inRaw.Count.ShouldBe(1);
inRaw[0].Scope.ShouldBe(NodeAclScopeKind.Namespace);
inUns.ShouldBeEmpty("a raw-namespace grant must not cover the UNS realm");
}
/// <summary>
/// Provenance is the granting <b>row's</b> <see cref="NodeAclScopeKind"/>, not one derived from the
/// structural depth the grant sits at. Two rows of different kinds planted at the same trie node
/// must report their own kinds — under any depth-derived scheme they would report the same one.
/// </summary>
[Fact]
public void MatchedGrant_Reports_The_Rows_Own_ScopeKind_Not_A_Depth_Derived_One()
{
var samePath = RawPath("Plant", "Modbus", "dev1");
var paths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] = new[] { new NodeAclPath(samePath) },
[new AclScopeRef(NodeAclScopeKind.RawTagGroup, "grp-1")] = new[] { new NodeAclPath(samePath) },
};
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.RawTagGroup, "grp-1", NodePermissions.WriteOperate),
};
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matches = trie.CollectMatches(RawScope("c1", "Plant", "Modbus", "dev1", "Speed"), ["cn=ops"]);
matches.Count.ShouldBe(2);
matches.Select(m => m.Scope).ShouldBe(
[NodeAclScopeKind.RawDevice, NodeAclScopeKind.RawTagGroup],
ignoreOrder: true,
"each grant carries the scope kind its own row was authored with");
}
/// <summary>
/// A scope addressing the realm itself (no segments) collects the realm-level and cluster-level
/// grants and nothing planted below it.
/// </summary>
[Fact]
public void RealmScope_With_No_Segments_Collects_Only_Cluster_And_Realm_Grants()
{
var paths = ScopePaths(
(NodeAclScopeKind.Namespace, NodeAclRealms.Raw, [NodeAclRealms.Raw]),
(NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1")));
var rows = new[]
{
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Browse),
Row("cn=ops", NodeAclScopeKind.Namespace, NodeAclRealms.Raw, NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.WriteOperate),
};
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
var matches = trie.CollectMatches(RawScope("c1"), ["cn=ops"]);
matches.Select(m => m.Scope).ShouldBe(
[NodeAclScopeKind.Cluster, NodeAclScopeKind.Namespace],
ignoreOrder: true,
"the device grant sits below the realm and must not be collected at the realm");
}
}
@@ -13,6 +13,9 @@ public sealed class TriePermissionEvaluatorTests
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
private readonly FakeTimeProvider _time = new();
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
private sealed class FakeTimeProvider : TimeProvider
{
/// <summary>Gets or sets the current UTC time.</summary>
@@ -46,22 +49,42 @@ public sealed class TriePermissionEvaluatorTests
MembershipVersion = 1,
};
/// <summary>One scope resolving to one path.</summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths(
params (NodeAclScopeKind Kind, string ScopeId, string[] Segments)[] entries) =>
entries.ToDictionary(
e => new AclScopeRef(e.Kind, e.ScopeId),
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath(e.Segments) });
/// <summary>A UNS-realm trie path: <c>uns / …segments…</c>.</summary>
private static string[] UnsPath(params string[] segments) => [NodeAclRealms.Uns, .. segments];
/// <summary>A raw-realm trie path: <c>raw / …segments…</c>.</summary>
private static string[] RawPath(params string[] segments) => [NodeAclRealms.Raw, .. segments];
/// <summary>The default UNS node the flag/staleness/generation tests evaluate against.</summary>
private static NodeScope Scope(string cluster = "c1") =>
new()
{
ClusterId = cluster,
NamespaceId = "ns",
UnsAreaId = "area",
UnsLineId = "line",
EquipmentId = "eq",
TagId = "tag",
Kind = NodeHierarchyKind.Equipment,
Kind = NodeHierarchyKind.Uns,
Segments = ["area", "line", "eq", "tag"],
};
private TriePermissionEvaluator MakeEvaluator(NodeAcl[] rows)
/// <summary>A node in the UNS realm addressed by its path below the realm segment.</summary>
private static NodeScope UnsScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Uns, Segments = segments };
/// <summary>A node in the raw realm addressed by its path below the realm segment.</summary>
private static NodeScope RawScope(string cluster, params string[] segments) =>
new() { ClusterId = cluster, Kind = NodeHierarchyKind.Raw, Segments = segments };
private TriePermissionEvaluator MakeEvaluator(
NodeAcl[] rows,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
{
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows, scopePaths ?? NoScopePaths));
return new TriePermissionEvaluator(cache, _time);
}
@@ -160,8 +183,8 @@ public sealed class TriePermissionEvaluatorTests
// The evaluator must still honour the session's bound generation 1, not generation 2.
var gen1Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build("c1", 1, [gen1Row]));
cache.Install(PermissionTrieBuilder.Build("c1", 2, [])); // gen 2 — grant revoked, now current
cache.Install(PermissionTrieBuilder.Build("c1", 1, [gen1Row], NoScopePaths));
cache.Install(PermissionTrieBuilder.Build("c1", 2, [], NoScopePaths)); // gen 2 — grant revoked, now current
var evaluator = new TriePermissionEvaluator(cache, _time);
var decision = evaluator.Authorize(
@@ -180,7 +203,7 @@ public sealed class TriePermissionEvaluatorTests
// The evaluator must fail closed rather than silently using the current generation.
var gen5Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build("c1", 5, [gen5Row]));
cache.Install(PermissionTrieBuilder.Build("c1", 5, [gen5Row], NoScopePaths));
var evaluator = new TriePermissionEvaluator(cache, _time);
// Session bound to generation 1, which was never installed / has been pruned.
@@ -234,4 +257,144 @@ public sealed class TriePermissionEvaluatorTests
TriePermissionEvaluator.MapOperationToPermission(op);
}
}
/// <summary>
/// End-to-end coverage of the sub-cluster walk: an Equipment-scoped grant authorizes a node
/// beneath that equipment, and denies the same operation on a sibling equipment. Every other test
/// in this class uses a cluster-scoped grant, which the walk collects at level 0 before it ever
/// descends — so without this pair nothing here exercises <see cref="NodeScope.Segments"/> at all.
/// </summary>
[Fact]
public void SubCluster_Grant_Allows_Beneath_Its_Equipment_And_Denies_A_Sibling()
{
var evaluator = MakeEvaluator(
[Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate)],
ScopePaths((NodeAclScopeKind.Equipment, "eq-A", UnsPath("area-1", "line-1", "eq-A"))));
var onEquipment = evaluator.Authorize(
Session(["cn=ops"]), OpcUaOperation.WriteOperate,
UnsScope("c1", "area-1", "line-1", "eq-A", "Setpoint"));
var onSibling = evaluator.Authorize(
Session(["cn=ops"]), OpcUaOperation.WriteOperate,
UnsScope("c1", "area-1", "line-1", "eq-B", "Setpoint"));
onEquipment.IsAllowed.ShouldBeTrue("the grant sits on the equipment the scope walks through");
onEquipment.Provenance.Count.ShouldBe(1);
onEquipment.Provenance[0].Scope.ShouldBe(NodeAclScopeKind.Equipment);
onSibling.IsAllowed.ShouldBeFalse("a grant on eq-A must not authorize eq-B");
}
/// <summary>
/// The same walk through the raw realm, over a path deep enough to have nested folders and nested
/// tag groups: a grant on the device authorizes a tag six levels below it, and not the sibling
/// device's tag.
/// </summary>
[Fact]
public void SubCluster_Grant_Walks_A_Deep_Raw_Path()
{
var evaluator = MakeEvaluator(
[Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read)],
ScopePaths((NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "CellA", "Modbus", "dev1"))));
var beneath = evaluator.Authorize(
Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "CellA", "Modbus", "dev1", "Motors", "Drive", "Speed"));
var sibling = evaluator.Authorize(
Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "CellA", "Modbus", "dev2", "Motors", "Drive", "Speed"));
beneath.IsAllowed.ShouldBeTrue();
beneath.Provenance[0].Scope.ShouldBe(NodeAclScopeKind.RawDevice,
"provenance names the row's own kind, not one derived from the depth it was planted at");
sibling.IsAllowed.ShouldBeFalse("the grant is anchored at dev1");
}
/// <summary>
/// The permission OR lives here, in <c>Authorize</c>: flags from grants matched at different trie
/// levels combine into one bitmask. Both legs are asserted with the same session and scope, so
/// neither "use the first match" nor "use the last match" can pass.
/// </summary>
[Fact]
public void Union_Across_Levels_Ors_Flags_From_Every_Match()
{
var evaluator = MakeEvaluator(
[
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.WriteOperate),
],
ScopePaths((NodeAclScopeKind.Equipment, "eq-A", UnsPath("area-1", "line-1", "eq-A"))));
var scope = UnsScope("c1", "area-1", "line-1", "eq-A", "Setpoint");
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, scope)
.IsAllowed.ShouldBeTrue("Read comes from the cluster-level grant");
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.WriteOperate, scope)
.IsAllowed.ShouldBeTrue("WriteOperate comes from the equipment-level grant");
}
/// <summary>
/// The union also spans principals: a session carrying two principals gets the OR of both their
/// grants, and neither principal alone carries both permissions.
/// </summary>
[Fact]
public void Union_Across_Principals_Ors_Flags()
{
NodeAcl[] rows =
[
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
];
var evaluator = MakeEvaluator(rows);
evaluator.Authorize(Session(["cn=readers", "cn=writers"]), OpcUaOperation.WriteOperate, Scope())
.IsAllowed.ShouldBeTrue();
evaluator.Authorize(Session(["cn=readers", "cn=writers"]), OpcUaOperation.Read, Scope())
.IsAllowed.ShouldBeTrue();
evaluator.Authorize(Session(["cn=readers"]), OpcUaOperation.WriteOperate, Scope())
.IsAllowed.ShouldBeFalse("cn=readers alone carries no write bit");
}
/// <summary>
/// A grant resolved into the raw realm must not authorize the UNS node of the same name. The realm
/// is level 1 of the walk and is derived from <see cref="NodeScope.Kind"/>, so a scope in the wrong
/// realm never reaches the grant.
/// </summary>
[Fact]
public void Grant_In_The_Raw_Realm_DoesNot_Authorize_A_Uns_Scope()
{
var evaluator = MakeEvaluator(
[Row("cn=ops", NodeAclScopeKind.RawDevice, "dev-1", NodePermissions.Read)],
ScopePaths((NodeAclScopeKind.RawDevice, "dev-1", RawPath("Plant", "Modbus", "dev1"))));
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "Modbus", "dev1", "Speed"))
.IsAllowed.ShouldBeTrue("the raw scope must be allowed, or the cross-realm leg proves nothing");
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
UnsScope("c1", "Plant", "Modbus", "dev1", "Speed"))
.IsAllowed.ShouldBeFalse("a raw-realm grant must not be reachable from a UNS scope");
}
/// <summary>
/// A case-drifted scope segment must not reach a grant — the trie's children compare with
/// <see cref="StringComparer.Ordinal"/>, matching OPC UA NodeId identity, so <c>Speed</c> and
/// <c>speed</c> are distinct nodes and a grant on one denies the other.
/// </summary>
[Fact]
public void Case_Drifted_Scope_Segment_Is_NotGranted()
{
var evaluator = MakeEvaluator(
[Row("cn=ops", NodeAclScopeKind.Tag, "tag-speed", NodePermissions.Read)],
ScopePaths((NodeAclScopeKind.Tag, "tag-speed", RawPath("Plant", "Modbus", "dev1", "Speed"))));
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "Modbus", "dev1", "Speed"))
.IsAllowed.ShouldBeTrue();
evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read,
RawScope("c1", "Plant", "Modbus", "dev1", "speed"))
.IsAllowed.ShouldBeFalse("segment matching is case-sensitive ordinal");
}
}
@@ -44,8 +44,11 @@ public class PageAuthorizationGuardTests
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
// ── FleetAdmin (1) ──
// ── FleetAdmin (2) ──
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
// Minting a key hands out a data-plane credential, and the secret is shown once at creation —
// the same authority level as editing role grants.
[typeof(ApiKeys)] = AdminUiPolicies.FleetAdmin,
// ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
// carries the RCL page's own policy, registered additively by AddAdminUI via
@@ -15,6 +15,12 @@ public sealed class ArtifactApiKeyStoreTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// No test here exercises a narrower-than-Cluster scope, so the resolved scope paths are empty.
/// </summary>
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
[Fact]
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
{
@@ -29,7 +35,7 @@ public sealed class ArtifactApiKeyStoreTests
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "scadabridge-dcl")],
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], NoScopePaths);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
@@ -41,16 +47,18 @@ public sealed class ArtifactApiKeyStoreTests
[Fact]
public async Task Only_cluster_scoped_grants_are_returned()
{
// Matches ConfigDbApiKeyStore, which filters the same way in SQL. Narrower scopes are not yet
// enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately under-grants rather than
// promoting a scoped grant's flags to server-wide authority.
// Narrower scopes are not yet enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately
// under-grants rather than promoting a scoped grant's flags to server-wide authority. Scope kind
// is the only narrowing this store performs — cluster confinement already happened in
// ParseDataPlaneAuthority, which is where it differs from ConfigDbApiKeyStore (that one filters
// on cluster itself; see ConfigDbApiKeyStoreTests).
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "p")],
[
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
], NoScopePaths);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
@@ -65,12 +73,12 @@ public sealed class ArtifactApiKeyStoreTests
var state = new DataPlaneAuthorityState();
var sut = new ArtifactApiKeyStore(state);
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopePaths);
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
var revoked = Key("abc", "p");
revoked.Enabled = false;
state.Apply("cluster-a", "rev-2", [revoked], []);
state.Apply("cluster-a", "rev-2", [revoked], [], NoScopePaths);
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
}
@@ -81,7 +89,8 @@ public sealed class ArtifactApiKeyStoreTests
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1", [],
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)]);
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)],
NoScopePaths);
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
@@ -0,0 +1,227 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// The key source for a node WITH a ConfigDb (#520 stage 2) — an admin-role node, including a fused
/// admin+driver one.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why cluster confinement is tested here and not on <c>ArtifactApiKeyStore</c>.</b> The two
/// stores read different things. The artifact store's snapshot was already narrowed to the node's
/// own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>
/// (<c>ClusterFilterMode.ScopeTo</c>), so it cannot see another cluster's rows at all. This store
/// queries the fleet-wide ConfigDb, so the confinement is its own job — and until it was added, a
/// Cluster-scoped grant authored in ANY cluster conferred an interim server-wide role on a fused
/// central node.
/// </para>
/// <para>
/// The in-memory EF provider is the convention in the ControlPlane/Runtime test projects; nothing
/// here touches the central SQL Server. Note it compares strings with LINQ-to-Objects ordinal
/// semantics, NOT SQL Server's case-insensitive collation — which is exactly why the ClusterId
/// match is done explicitly with <see cref="StringComparison.OrdinalIgnoreCase"/> in the store
/// rather than left to the provider. <see cref="A_ClusterId_matches_case_insensitively"/> pins it.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStoreTests
{
private const string ThisCluster = "SITE-A";
private const string OtherCluster = "SITE-B";
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// THE REGRESSION TEST. A Cluster-scoped grant authored in another cluster must not reach this
/// node — it would collapse into a server-wide <c>WriteOperate</c> role via
/// <c>ApiKeyInterimRoleMapper</c>, i.e. write authority granted in SITE-B taking effect in SITE-A.
/// </summary>
[Fact]
public async Task A_cluster_scoped_grant_from_another_cluster_confers_nothing()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "scadabridge-dcl")],
acls: [Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
// The KEY still resolves — keys are fleet-wide credentials (ApiKey carries no ClusterId); it is
// the grants that are cluster-bound. Asserting a null lookup here would pass for the wrong reason.
found.ShouldNotBeNull();
found!.Grants.ShouldBeEmpty();
}
/// <summary>
/// The positive control for the test above: without it, a "fix" that returned no grants at all
/// would pass.
/// </summary>
[Fact]
public async Task A_cluster_scoped_grant_in_this_cluster_still_confers_its_permissions()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "scadabridge-dcl")],
acls:
[
Acl("acl-mine", ThisCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
}
/// <summary>
/// A legacy / single-cluster node carries no <c>cluster-{ClusterId}</c> role, so its ClusterId is
/// null and NO filter applies — mirroring the artifact path's <c>ClusterFilterMode.None</c>.
/// Failing closed here would silently strip every grant on an existing deployment.
/// </summary>
[Fact]
public async Task A_node_with_no_cluster_scoped_role_sees_every_cluster_scoped_grant()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls:
[
Acl("acl-a", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-b", OtherCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, clusterId: null).FindAsync("abc", Now, default);
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-a", "acl-b"], ignoreOrder: true);
}
/// <summary>
/// The node's ClusterId comes from its role suffix, and that arrives in either the operator's
/// spelling (<c>Cluster:Roles</c> binds verbatim) or lower-cased (<c>OTOPCUA_ROLES</c> goes through
/// <c>RoleParser.Parse</c>, which calls <c>ToLowerInvariant</c>). The ACL row's ClusterId is
/// authored independently. A case-sensitive match would therefore strip every grant on a node
/// configured the second way — so the match is OrdinalIgnoreCase, the same comparison
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> uses.
/// </summary>
[Fact]
public async Task A_ClusterId_matches_case_insensitively()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls: [Acl("acl-mine", "SITE-A", "p", NodeAclScopeKind.Cluster, NodePermissions.Read)]);
var found = await new ConfigDbApiKeyStore(factory, "site-a").FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine");
}
/// <summary>
/// <c>FindGrantsAsync</c> is the anonymous principal's path — no credential, so it never goes
/// through <c>FindAsync</c>. It must be confined to the node's cluster too.
/// </summary>
[Fact]
public async Task Anonymous_grants_are_cluster_confined_on_their_own_path()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [],
acls:
[
Acl("acl-anon-mine", ThisCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-anon-other", OtherCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
]);
var grants = await new ConfigDbApiKeyStore(factory, ThisCluster)
.FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-anon-mine");
}
/// <summary>
/// Narrower-than-Cluster scopes are still excluded (stage 5 widens this) — the cluster filter is
/// an ADDITIONAL narrowing, not a replacement for the scope-kind one.
/// </summary>
[Fact]
public async Task A_narrower_scope_in_this_cluster_is_still_excluded()
{
var factory = NewInMemoryDbFactory();
Seed(factory,
keys: [Key("abc", "p")],
acls:
[
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
}
/// <summary>
/// The ClusterId derivation itself: the same one <c>ClusterRoleInfo</c> performs for
/// <c>IClusterRoleInfo.ClusterId</c>, off the node's OWN configured roles.
/// </summary>
[Fact]
public void ResolveClusterId_reads_the_nodes_own_cluster_scoped_role()
{
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["driver", "cluster-SITE-A"] })
.ShouldBe("SITE-A");
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["admin", "driver"] })
.ShouldBeNull();
ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions())
.ShouldBeNull();
}
private static void Seed(
IDbContextFactory<OtOpcUaConfigDbContext> factory,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> acls)
{
using var db = factory.CreateDbContext();
db.ApiKeys.AddRange(keys);
db.NodeAcls.AddRange(acls);
db.SaveChanges();
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyRowId = Guid.NewGuid(),
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(
string id, string clusterId, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = id,
ClusterId = clusterId,
Principal = principal,
ScopeKind = scope,
PermissionFlags = flags,
};
private static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory() =>
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"));
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a context over this test's own in-memory database.</summary>
/// <returns>A new <see cref="OtOpcUaConfigDbContext"/>.</returns>
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName).Options);
}
}
@@ -10,6 +10,9 @@
<ItemGroup>
<PackageReference Include="xunit"/>
<PackageReference Include="Shouldly"/>
<!-- ConfigDbApiKeyStoreTests drives the store against a real EF query, the same in-memory
provider convention the ControlPlane/Runtime test projects use. No central SQL Server. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,183 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// End-to-end coverage of the ACL scope path as it actually runs on a node (#520 stage 3):
/// artifact bytes → <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/> → scope paths →
/// <see cref="PermissionTrieBuilder.Build"/> → <see cref="PermissionTrie.CollectMatches"/>.
/// </summary>
/// <remarks>
/// <para>
/// This is the coverage the design doc called out as missing, and it was missing in a specific
/// way worth remembering: the pre-stage-3 tests all hand-built the <c>scopePaths</c> dictionary in
/// the test body with exactly the segments the walker expected, so the walker was pinned against
/// the test's idea of the format rather than against any producer. Nothing proved a real artifact
/// could yield a matching grant, and in fact nothing could have — there was no producer at all.
/// </para>
/// <para>
/// So the artifact JSON here is deliberately hand-written rather than composed: it is standing in
/// for the wire format, and the parser must agree with <em>it</em>. The composer→parser agreement
/// is pinned separately, in <c>ControlPlane.Tests/DataPlaneAuthorityArtifactTests</c>.
/// </para>
/// </remarks>
public sealed class DeploymentArtifactScopePathTests
{
private const string NodeId = "node-a:4840";
// Plant/Line1 → ModbusDrv → dev1 → Fast → Speed, projected into equipment West/L1/Mixer as "MixerSpeed".
private const string Artifact = """
{
"RawFolders": [
{"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"},
{"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"}
],
"DriverInstances": [
{"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"}
],
"Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}],
"TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}],
"Tags": [{"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}],
"UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}],
"UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}],
"Equipment": [{"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}],
"UnsTagReferences": [
{"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"}
],
"NodeAcls": [
{"NodeAclId":"acl-dev","ClusterId":"c1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2},
{"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16},
{"NodeAclId":"acl-dangling","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-gone","PermissionFlags":16}
]
}
""";
private static (PermissionTrie Trie, List<PermissionTrieBuildDiagnostic> Diagnostics) BuildFromArtifact()
{
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
var trie = PermissionTrieBuilder.Build("c1", 1, projection.Grants, projection.ScopePaths, diagnostics.Add);
return (trie, diagnostics);
}
[Fact]
public void A_raw_device_grant_from_the_artifact_matches_a_tag_beneath_that_device()
{
var (trie, _) = BuildFromArtifact();
var matches = trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
},
["p"]);
var match = matches.ShouldHaveSingleItem();
match.PermissionFlags.ShouldBe(NodePermissions.Read);
// Provenance is the authored kind, not the depth the grant happens to sit at.
match.Scope.ShouldBe(NodeAclScopeKind.RawDevice);
}
[Fact]
public void A_tag_under_a_DIFFERENT_device_does_not_match_that_grant()
{
// The negative control. Without it the test above passes for any trie that returns every grant it
// holds regardless of path — which is exactly what a broken walk would do.
var (trie, _) = BuildFromArtifact();
trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["Plant", "Line1", "ModbusDrv", "otherdev", "Fast", "Speed"],
},
["p"]).ShouldBeEmpty();
}
[Fact]
public void The_same_grant_set_matches_through_the_UNS_realm_too()
{
// The equipment grant is authored once and must be reachable at the UNS NodeId, which is a
// completely different path from the raw one for the same underlying value.
var (trie, _) = BuildFromArtifact();
var matches = trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Uns,
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
},
["p"]);
matches.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
}
[Fact]
public void A_raw_scope_does_not_pick_up_a_UNS_grant_at_the_same_depth()
{
// Realm isolation. "West/L1/Mixer" and a three-segment raw path are the same depth, so without the
// realm gate at level 1 a UNS grant would match a raw node whose folder/driver/device happened to
// be named the same. The two trees are authored independently, so that collision is not exotic.
var (trie, _) = BuildFromArtifact();
trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
},
["p"]).ShouldBeEmpty();
}
[Fact]
public void A_dangling_scope_is_dropped_with_a_diagnostic_rather_than_planted()
{
var (_, diagnostics) = BuildFromArtifact();
var diagnostic = diagnostics.ShouldHaveSingleItem();
diagnostic.NodeAclId.ShouldBe("acl-dangling");
diagnostic.Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
}
[Fact]
public void Case_drift_in_a_path_segment_grants_nothing()
{
// Raw sibling names are unique under an ordinal comparer, so "Speed" and "speed" can both exist
// under one device. The trie compared OrdinalIgnoreCase before stage 3, which would have let a
// grant on either match both. This goes red if the comparer is reverted.
var (trie, _) = BuildFromArtifact();
trie.CollectMatches(
new NodeScope
{
ClusterId = "c1",
Kind = NodeHierarchyKind.Raw,
Segments = ["plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
},
["p"]).ShouldBeEmpty();
}
[Fact]
public void An_artifact_with_no_topology_resolves_no_scopes()
{
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
Encoding.UTF8.GetBytes("""
{"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-x","PermissionFlags":1}]}
"""),
NodeId);
projection.Grants.ShouldHaveSingleItem();
projection.ScopePaths.ShouldBeEmpty();
}
}