diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/NodeScopeResolver.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/NodeScopeResolver.cs
index 812f716..137b9c1 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/Security/NodeScopeResolver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/NodeScopeResolver.cs
@@ -1,42 +1,83 @@
+using System.Collections.Frozen;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
///
/// Maps a driver-side full reference (e.g. "TestMachine_001/Oven/SetPoint") to the
-/// the Phase 6.2 evaluator walks. Today a simplified resolver that
-/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment
-/// path lookup from the live Configuration DB is a Stream C.12 follow-up.
+/// the Phase 6.2 evaluator walks. Supports two modes:
+///
+/// -
+/// Cluster-only (pre-ADR-001) — when no path index is supplied the resolver
+/// returns a flat ClusterId + TagId scope. Sufficient while the
+/// Config-DB-driven Equipment walker isn't live; Cluster-level grants cascade to every
+/// tag below per decision #129, so finer per-Equipment grants are effectively
+/// cluster-wide at dispatch.
+///
+/// -
+/// Full-path (post-ADR-001 Task B) — when an index is supplied, the resolver
+/// joins the full reference against the index to produce a complete
+/// Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag scope. Unblocks
+/// per-Equipment / per-UnsLine ACL grants at the dispatch layer.
+///
+///
///
///
-/// The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants
-/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The
-/// finer hierarchy only matters when operators want per-area or per-equipment grants;
-/// those still work for Cluster-level grants, and landing the finer resolution in a
-/// follow-up doesn't regress the base security model.
+/// The index is pre-loaded by the Server bootstrap against the published generation;
+/// the resolver itself does no live DB access. Resolve is O(1) dictionary lookup on the
+/// hot path; the fallback for unknown fullReference strings produces the same cluster-only
+/// scope the pre-ADR-001 resolver returned — new tags picked up via driver discovery but
+/// not yet indexed (e.g. between a DiscoverAsync result and the next generation publish)
+/// stay addressable without a scope-resolver crash.
///
-/// Thread-safety: the resolver is stateless once constructed. Callers may cache a
-/// single instance per DriverNodeManager without locks.
+/// Thread-safety: both constructor paths freeze inputs into immutable state. Callers
+/// may cache a single instance per DriverNodeManager without locks. Swap atomically on
+/// generation change via the server's publish pipeline.
///
public sealed class NodeScopeResolver
{
private readonly string _clusterId;
+ private readonly FrozenDictionary? _index;
+ /// Cluster-only resolver — pre-ADR-001 behavior. Kept for Server processes that
+ /// haven't wired the Config-DB snapshot flow yet.
public NodeScopeResolver(string clusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
_clusterId = clusterId;
+ _index = null;
+ }
+
+ ///
+ /// Full-path resolver (ADR-001 Task B). maps each known
+ /// driver-side full reference to its pre-resolved carrying
+ /// every UNS level populated. Entries are typically produced by joining
+ /// Tag → Equipment → UnsLine → UnsArea rows of the published generation against
+ /// the driver's discovered full references (or against Tag.TagConfig directly
+ /// when the walker is config-primary per ADR-001 Option A).
+ ///
+ public NodeScopeResolver(string clusterId, IReadOnlyDictionary pathIndex)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
+ ArgumentNullException.ThrowIfNull(pathIndex);
+ _clusterId = clusterId;
+ _index = pathIndex.ToFrozenDictionary(StringComparer.Ordinal);
}
///
/// Resolve a node scope for the given driver-side .
- /// Phase 1 shape: returns ClusterId + TagId = fullReference only;
- /// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will
- /// join against the Configuration DB to populate the full path.
+ /// Returns the indexed full-path scope when available; falls back to cluster-only
+ /// (TagId populated only) when the index is absent or the reference isn't indexed.
+ /// The fallback is the same shape the pre-ADR-001 resolver produced, so the authz
+ /// evaluator behaves identically for un-indexed references.
///
public NodeScope Resolve(string fullReference)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
+
+ if (_index is not null && _index.TryGetValue(fullReference, out var indexed))
+ return indexed;
+
return new NodeScope
{
ClusterId = _clusterId,
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/ScopePathIndexBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/ScopePathIndexBuilder.cs
new file mode 100644
index 0000000..b356659
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/ScopePathIndexBuilder.cs
@@ -0,0 +1,81 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.Security;
+
+///
+/// Builds the path index consumed by
+/// from a Config-DB snapshot of a single published generation. Runs once per generation
+/// (or on every generation change) at the Server bootstrap layer; the produced index is
+/// immutable + hot-path readable per ADR-001 Task B.
+///
+///
+/// The index key is the driver-side full reference (Tag.TagConfig) — the same
+/// string the dispatch layer passes to . The value
+/// is a with every UNS level populated:
+/// ClusterId / NamespaceId / UnsAreaId / UnsLineId / EquipmentId / TagId. Tag rows
+/// with null EquipmentId (SystemPlatform-namespace Galaxy tags per decision #120)
+/// are excluded from the index — the cluster-only fallback path in the resolver handles
+/// them without needing an index entry.
+///
+/// Duplicate keys are not expected but would be indicative of corrupt data — the
+/// builder throws on collision so a config drift
+/// surfaces at bootstrap instead of producing silently-last-wins scopes at dispatch.
+///
+public static class ScopePathIndexBuilder
+{
+ ///
+ /// Build a fullReference → NodeScope index from the four Config-DB collections for a
+ /// single namespace. Callers must filter inputs to a single
+ /// + the same upstream.
+ ///
+ /// Owning cluster — populates .
+ /// Owning namespace — populates .
+ /// Pre-loaded rows for the namespace.
+ public static IReadOnlyDictionary Build(
+ string clusterId,
+ string namespaceId,
+ EquipmentNamespaceContent content)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(namespaceId);
+ ArgumentNullException.ThrowIfNull(content);
+
+ var areaByLine = content.Lines.ToDictionary(l => l.UnsLineId, l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase);
+ var lineByEquipment = content.Equipment.ToDictionary(e => e.EquipmentId, e => e.UnsLineId, StringComparer.OrdinalIgnoreCase);
+
+ var index = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var tag in content.Tags)
+ {
+ // Null EquipmentId = SystemPlatform-namespace tag per decision #110 — skip; the
+ // cluster-only resolver fallback handles those without needing an index entry.
+ if (string.IsNullOrEmpty(tag.EquipmentId)) continue;
+
+ // Broken FK — Tag references a missing Equipment row. Skip rather than crash;
+ // sp_ValidateDraft should have caught this at publish, so any drift here is
+ // unexpected but non-fatal.
+ if (!lineByEquipment.TryGetValue(tag.EquipmentId, out var lineId)) continue;
+ if (!areaByLine.TryGetValue(lineId, out var areaId)) continue;
+
+ var scope = new NodeScope
+ {
+ ClusterId = clusterId,
+ NamespaceId = namespaceId,
+ UnsAreaId = areaId,
+ UnsLineId = lineId,
+ EquipmentId = tag.EquipmentId,
+ TagId = tag.TagConfig,
+ Kind = NodeHierarchyKind.Equipment,
+ };
+
+ if (!index.TryAdd(tag.TagConfig, scope))
+ throw new InvalidOperationException(
+ $"Duplicate fullReference '{tag.TagConfig}' in Equipment namespace '{namespaceId}'. " +
+ "Config data is corrupt — two Tag rows produced the same wire-level address.");
+ }
+
+ return index;
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs
new file mode 100644
index 0000000..f2d5fcc
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs
@@ -0,0 +1,180 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Server.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
+
+///
+/// End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
+/// Walks the full dispatch flow for a read against an Equipment / Identification
+/// property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
+/// Proves the contract the IdentificationFolderBuilder docstring promises — a user
+/// without the Equipment-scope grant gets denied on the Identification sub-folder the
+/// same way they would be denied on the Equipment node itself, because they share the
+/// Equipment ScopeId (no new scope level for Identification per the builder's remark
+/// section).
+///
+[Trait("Category", "Unit")]
+public sealed class EquipmentIdentificationAuthzTests
+{
+ private const string Cluster = "c-warsaw";
+ private const string Namespace = "ns-plc";
+
+ [Fact]
+ public void Authorized_Group_Read_Granted_On_Identification_Property()
+ {
+ var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
+ var scope = resolver.Resolve("plcaddr-manufacturer");
+
+ var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
+ gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Unauthorized_Group_Read_Denied_On_Identification_Property()
+ {
+ // The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
+ // without the grant gets BadUserAccessDenied on both the Equipment node + its
+ // Identification variables." This test proves the evaluator side of that contract;
+ // the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
+ // already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
+ var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
+ var scope = resolver.Resolve("plcaddr-manufacturer");
+
+ var identity = new FakeIdentity("bob", ["cn=other-team"]);
+ gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
+ {
+ // Identification properties share their parent Equipment's ScopeId (no new scope
+ // level). An Equipment-scope grant must therefore read both — the Equipment's tag
+ // AND its Identification properties — via the same evaluator call path.
+ var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
+
+ var tagScope = resolver.Resolve("plcaddr-temperature");
+ var identityScope = resolver.Resolve("plcaddr-manufacturer");
+
+ var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
+ gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
+ gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
+ {
+ // Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
+ // so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
+ // motivation for populating the full UNS path at resolve time.
+ var (gate, resolver) = BuildEvaluator(
+ equipmentGrantGroup: "cn=oven-3-operators",
+ equipmentIdForGrant: "eq-oven-3");
+
+ var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7
+
+ var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
+ gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
+ }
+
+ // ----- harness -----
+
+ ///
+ /// Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
+ /// Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
+ /// Equipment scope for , and a ScopePathIndex
+ /// populated via ScopePathIndexBuilder from the same Config-DB row set the
+ /// EquipmentNodeWalker would consume at address-space build.
+ ///
+ private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
+ string equipmentGrantGroup,
+ string equipmentIdForGrant = "eq-oven-3")
+ {
+ var (content, scopeIndex) = BuildFixture();
+ var resolver = new NodeScopeResolver(Cluster, scopeIndex);
+
+ var aclRow = new NodeAcl
+ {
+ NodeAclRowId = Guid.NewGuid(),
+ NodeAclId = Guid.NewGuid().ToString(),
+ GenerationId = 1,
+ ClusterId = Cluster,
+ LdapGroup = equipmentGrantGroup,
+ ScopeKind = NodeAclScopeKind.Equipment,
+ ScopeId = equipmentIdForGrant,
+ PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
+ };
+ var paths = new Dictionary
+ {
+ [equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
+ };
+
+ var cache = new PermissionTrieCache();
+ cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
+ var evaluator = new TriePermissionEvaluator(cache);
+ var gate = new AuthorizationGate(evaluator, strictMode: true);
+
+ _ = content;
+ return (gate, resolver);
+ }
+
+ private static (EquipmentNamespaceContent, IReadOnlyDictionary) BuildFixture()
+ {
+ var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
+ var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
+
+ var oven = new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
+ EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
+ DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
+ MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
+ };
+ var press = new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
+ EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
+ DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
+ MachineCode = "MC-press-7",
+ };
+
+ // Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
+ // reference the dispatch layer passes to NodeScopeResolver.Resolve.
+ var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
+ var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
+ var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");
+
+ var content = new EquipmentNamespaceContent(
+ Areas: [area],
+ Lines: [line],
+ Equipment: [oven, press],
+ Tags: [tempTag, mfgTag, pressTempTag]);
+
+ var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
+ return (content, index);
+ }
+
+ private static Tag NewTag(string tagId, string name, string dataType, string address, string equipmentId) => new()
+ {
+ TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId,
+ DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name,
+ DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite, TagConfig = address,
+ };
+
+ private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
+ {
+ public FakeIdentity(string name, IReadOnlyList groups)
+ {
+ DisplayName = name;
+ LdapGroups = groups;
+ }
+ public new string DisplayName { get; }
+ public IReadOnlyList LdapGroups { get; }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeScopeResolverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeScopeResolverTests.cs
index b5bbbec..d6afc75 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeScopeResolverTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeScopeResolverTests.cs
@@ -21,19 +21,59 @@ public sealed class NodeScopeResolverTests
}
[Fact]
- public void Resolve_Leaves_UnsPath_Null_For_Phase1()
+ public void Resolve_Leaves_UnsPath_Null_When_NoIndexSupplied()
{
var resolver = new NodeScopeResolver("c-1");
var scope = resolver.Resolve("tag-1");
- // Phase 1 flat scope — finer resolution tracked as Stream C.12 follow-up.
+ // Cluster-only fallback path — used pre-ADR-001 and still the active path for
+ // unindexed references (e.g. driver-discovered tags that have no Tag row yet).
scope.NamespaceId.ShouldBeNull();
scope.UnsAreaId.ShouldBeNull();
scope.UnsLineId.ShouldBeNull();
scope.EquipmentId.ShouldBeNull();
}
+ [Fact]
+ public void Resolve_Returns_IndexedScope_When_FullReferenceFound()
+ {
+ var index = new Dictionary
+ {
+ ["plcaddr-01"] = new NodeScope
+ {
+ ClusterId = "c-1", NamespaceId = "ns-plc", UnsAreaId = "area-1",
+ UnsLineId = "line-a", EquipmentId = "eq-oven-3", TagId = "plcaddr-01",
+ Kind = NodeHierarchyKind.Equipment,
+ },
+ };
+ var resolver = new NodeScopeResolver("c-1", index);
+
+ var scope = resolver.Resolve("plcaddr-01");
+
+ scope.UnsAreaId.ShouldBe("area-1");
+ scope.UnsLineId.ShouldBe("line-a");
+ scope.EquipmentId.ShouldBe("eq-oven-3");
+ scope.TagId.ShouldBe("plcaddr-01");
+ scope.NamespaceId.ShouldBe("ns-plc");
+ }
+
+ [Fact]
+ public void Resolve_FallsBack_To_ClusterOnly_When_Reference_NotIndexed()
+ {
+ var index = new Dictionary
+ {
+ ["plcaddr-01"] = new NodeScope { ClusterId = "c-1", TagId = "plcaddr-01", Kind = NodeHierarchyKind.Equipment },
+ };
+ var resolver = new NodeScopeResolver("c-1", index);
+
+ var scope = resolver.Resolve("not-in-index");
+
+ scope.ClusterId.ShouldBe("c-1");
+ scope.TagId.ShouldBe("not-in-index");
+ scope.EquipmentId.ShouldBeNull();
+ }
+
[Fact]
public void Resolve_Throws_OnEmptyFullReference()
{