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
@@ -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();
}
}