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() {