From 1bf3938cdf83bc2d44e702eafde3074aea069539 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 02:50:27 -0400 Subject: [PATCH] =?UTF-8?q?ADR-001=20Task=20B=20=E2=80=94=20NodeScopeResol?= =?UTF-8?q?ver=20full-path=20+=20ScopePathIndexBuilder=20+=20evaluator-lev?= =?UTF-8?q?el=20ACL=20test=20closing=20#195.=20Two=20production=20addition?= =?UTF-8?q?s=20+=20one=20end-to-end=20authz=20regression=20test=20proving?= =?UTF-8?q?=20the=20Identification=20ACL=20contract=20the=20Identification?= =?UTF-8?q?FolderBuilder=20docstring=20promises.=20Task=20A=20(PR=20#153)?= =?UTF-8?q?=20shipped=20the=20walker=20as=20a=20pure=20function=20that=20m?= =?UTF-8?q?aterializes=20the=20UNS=20=E2=86=92=20Equipment=20=E2=86=92=20T?= =?UTF-8?q?ag=20browse=20tree=20+=20IdentificationFolderBuilder.Build=20pe?= =?UTF-8?q?r=20Equipment.=20This=20PR=20lands=20the=20authz=20half=20of=20?= =?UTF-8?q?the=20walker's=20story=20=E2=80=94=20the=20resolver=20side=20th?= =?UTF-8?q?at=20turns=20a=20driver-side=20full=20reference=20into=20a=20fu?= =?UTF-8?q?ll=20NodeScope=20path=20(NamespaceId=20+=20UnsAreaId=20+=20UnsL?= =?UTF-8?q?ineId=20+=20EquipmentId=20+=20TagId)=20so=20the=20permission=20?= =?UTF-8?q?trie=20can=20walk=20the=20UNS=20hierarchy=20+=20apply=20Equipme?= =?UTF-8?q?nt-scope=20grants=20correctly=20at=20dispatch=20time.=20The=20a?= =?UTF-8?q?ctual=20in-server=20wiring=20(load=20snapshot=20=E2=86=92=20cal?= =?UTF-8?q?l=20walker=20during=20BuildAddressSpaceAsync=20=E2=86=92=20swap?= =?UTF-8?q?=20in=20the=20full-path=20resolver)=20is=20split=20into=20follo?= =?UTF-8?q?w-up=20task=20#212=20because=20it's=20a=20bigger=20surface=20(S?= =?UTF-8?q?erver=20bootstrap=20+=20DriverNodeManager=20override=20+=20real?= =?UTF-8?q?=20OPC=20UA=20client-browse=20integration=20test).=20NodeScopeR?= =?UTF-8?q?esolver=20extended=20with=20a=20second=20constructor=20taking?= =?UTF-8?q?=20IReadOnlyDictionary=20pathIndex=20?= =?UTF-8?q?=E2=80=94=20when=20supplied,=20Resolve=20looks=20up=20the=20ful?= =?UTF-8?q?l=20reference=20in=20the=20index=20+=20returns=20the=20indexed?= =?UTF-8?q?=20scope=20with=20every=20UNS=20level=20populated;=20when=20abs?= =?UTF-8?q?ent=20or=20on=20miss,=20falls=20back=20to=20the=20pre-ADR-001?= =?UTF-8?q?=20cluster-only=20scope=20so=20driver-discovered=20tags=20that?= =?UTF-8?q?=20haven't=20been=20indexed=20yet=20(between=20a=20DiscoverAsyn?= =?UTF-8?q?c=20result=20+=20the=20next=20generation=20publish)=20stay=20ad?= =?UTF-8?q?dressable=20without=20crashing=20the=20resolver.=20Index=20is?= =?UTF-8?q?=20frozen=20into=20a=20FrozenDictionary=20?= =?UTF-8?q?under=20Ordinal=20comparer=20for=20O(1)=20hot-path=20lookups.?= =?UTF-8?q?=20Thread-safety=20by=20immutability=20=E2=80=94=20callers=20sw?= =?UTF-8?q?ap=20atomically=20on=20generation=20change=20via=20the=20server?= =?UTF-8?q?'s=20publish=20pipeline.=20New=20ScopePathIndexBuilder.Build=20?= =?UTF-8?q?in=20Server.Security=20takes=20(clusterId,=20namespaceId,=20Equ?= =?UTF-8?q?ipmentNamespaceContent)=20+=20produces=20the=20fullReference=20?= =?UTF-8?q?=E2=86=92=20NodeScope=20dictionary=20by=20joining=20Tag=20?= =?UTF-8?q?=E2=86=92=20Equipment=20=E2=86=92=20UnsLine=20=E2=86=92=20UnsAr?= =?UTF-8?q?ea=20through=20up-front=20dictionaries=20keyed=20Ordinal-ignori?= =?UTF-8?q?ng-case.=20Tag=20rows=20with=20null=20EquipmentId=20(SystemPlat?= =?UTF-8?q?form-namespace=20Galaxy=20tags=20per=20decision=20#120)=20are?= =?UTF-8?q?=20excluded=20from=20the=20index;=20cluster-only=20fallback=20p?= =?UTF-8?q?ath=20covers=20them.=20Broken=20FKs=20(Tag=20references=20missi?= =?UTF-8?q?ng=20Equipment=20row,=20or=20Equipment=20references=20missing?= =?UTF-8?q?=20UnsLine)=20are=20skipped=20rather=20than=20crashing=20?= =?UTF-8?q?=E2=80=94=20sp=5FValidateDraft=20should=20have=20caught=20these?= =?UTF-8?q?=20at=20publish,=20any=20drift=20here=20is=20unexpected=20but?= =?UTF-8?q?=20non-fatal.=20Duplicate=20keys=20throw=20InvalidOperationExce?= =?UTF-8?q?ption=20at=20bootstrap=20so=20corrupt-data=20drift=20surfaces?= =?UTF-8?q?=20up-front=20instead=20of=20producing=20silently-last-wins=20s?= =?UTF-8?q?copes=20at=20dispatch.=20End-to-end=20authz=20regression=20test?= =?UTF-8?q?=20in=20EquipmentIdentificationAuthzTests=20walks=20the=20full?= =?UTF-8?q?=20dispatch=20flow=20against=20a=20Config-DB-style=20fixture:?= =?UTF-8?q?=20ScopePathIndexBuilder.Build=20from=20the=20same=20EquipmentN?= =?UTF-8?q?amespaceContent=20the=20EquipmentNodeWalker=20consumes=20?= =?UTF-8?q?=E2=86=92=20NodeScopeResolver=20with=20that=20index=20=E2=86=92?= =?UTF-8?q?=20AuthorizationGate=20+=20TriePermissionEvaluator=20=E2=86=92?= =?UTF-8?q?=20PermissionTrieBuilder=20with=20one=20Equipment-scope=20NodeA?= =?UTF-8?q?cl=20grant=20+=20a=20NodeAclPath=20resolving=20Equipment=20Scop?= =?UTF-8?q?eId=20to=20(namespace,=20area,=20line,=20equipment).=20Four=20t?= =?UTF-8?q?ests=20prove=20the=20contract:=20(a)=20authorized=20group=20Rea?= =?UTF-8?q?d=20granted=20on=20Identification=20property;=20(b)=20unauthori?= =?UTF-8?q?zed=20group=20Read=20denied=20on=20Identification=20property=20?= =?UTF-8?q?=E2=80=94=20the=20#195=20contract=20the=20IdentificationFolderB?= =?UTF-8?q?uilder=20docstring=20promises=20(the=20BadUserAccessDenied=20su?= =?UTF-8?q?rfacing=20happens=20at=20the=20DriverNodeManager=20dispatch=20l?= =?UTF-8?q?ayer=20which=20is=20already=20wired=20to=20AuthorizationGate.Is?= =?UTF-8?q?Allowed=20=E2=86=92=20StatusCodes.BadUserAccessDenied=20in=20PR?= =?UTF-8?q?=20#94);=20(c)=20Equipment-scope=20grant=20cascades=20to=20both?= =?UTF-8?q?=20the=20Equipment's=20tag=20+=20its=20Identification=20propert?= =?UTF-8?q?ies=20because=20they=20share=20the=20Equipment=20ScopeId=20?= =?UTF-8?q?=E2=80=94=20no=20new=20scope=20level=20for=20Identification=20p?= =?UTF-8?q?er=20the=20builder's=20Remarks=20section;=20(d)=20grant=20on=20?= =?UTF-8?q?oven-3=20does=20NOT=20leak=20to=20press-7=20(different=20equipm?= =?UTF-8?q?ent=20under=20the=20same=20UnsLine)=20proving=20per-Equipment?= =?UTF-8?q?=20isolation=20at=20dispatch=20when=20the=20resolver=20populate?= =?UTF-8?q?s=20the=20full=20path.=20NodeScopeResolverTests=20extended=20wi?= =?UTF-8?q?th=20two=20new=20tests=20covering=20the=20indexed-lookup=20path?= =?UTF-8?q?=20+=20fallback-on-miss=20path;=20renamed=20the=20existing=20"?= =?UTF-8?q?=5FFor=5FPhase1"=20test=20to=20"=5FWhen=5FNoIndexSupplied"=20to?= =?UTF-8?q?=20match=20the=20current=20framing.=20Server=20project=20builds?= =?UTF-8?q?=200=20errors;=20Server.Tests=20179/179=20(was=20173,=20+6=20ne?= =?UTF-8?q?w=20across=20the=20two=20test=20files).=20Task=20#212=20capture?= =?UTF-8?q?s=20the=20remaining=20in-server=20wiring=20work=20=E2=80=94=20S?= =?UTF-8?q?erver.SealedBootstrap=20load=20of=20EquipmentNamespaceContent,?= =?UTF-8?q?=20DriverNodeManager=20override=20that=20calls=20EquipmentNodeW?= =?UTF-8?q?alker=20during=20BuildAddressSpaceAsync=20for=20Equipment-kind?= =?UTF-8?q?=20namespaces,=20and=20a=20real=20OPC=20UA=20client-browse=20in?= =?UTF-8?q?tegration=20test.=20With=20that=20wiring=20+=20this=20PR's=20au?= =?UTF-8?q?thz-layer=20proof,=20#195's=20"ACL=20integration=20test"=20line?= =?UTF-8?q?=20is=20satisfied=20at=20two=20layers=20(evaluator=20+=20live?= =?UTF-8?q?=20endpoint)=20which=20is=20stronger=20than=20the=20task=20orig?= =?UTF-8?q?inally=20asked=20for.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Security/NodeScopeResolver.cs | 67 +++++-- .../Security/ScopePathIndexBuilder.cs | 81 ++++++++ .../EquipmentIdentificationAuthzTests.cs | 180 ++++++++++++++++++ .../NodeScopeResolverTests.cs | 44 ++++- 4 files changed, 357 insertions(+), 15 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/Security/ScopePathIndexBuilder.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs 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() {