Ships Stream B.1-B.6 — the data-plane authorization engine Phase 6.2 runs on.
Integration into OPC UA dispatch (Stream C — Read / Write / HistoryRead /
Subscribe / Browse / Call etc.) is the next PR on this branch.
New Core.Abstractions:
- OpcUaOperation enum enumerates every OPC UA surface the evaluator gates:
Browse, Read, WriteOperate/Tune/Configure (split by SecurityClassification),
HistoryRead, HistoryUpdate, CreateMonitoredItems, TransferSubscriptions,
Call, AlarmAcknowledge/Confirm/Shelve. Stream C maps each one back to its
dispatch call site.
New Core.Authorization namespace:
- NodeScope record + NodeHierarchyKind — 6-level scope addressing for
Equipment-kind (UNS) namespaces, folder-segment walk for SystemPlatform-kind
(Galaxy). NodeScope carries a Kind selector so the evaluator knows which
hierarchy to descend.
- AuthorizationDecision { Verdict, Provenance } + AuthorizationVerdict
{Allow, NotGranted, Denied} + MatchedGrant. Tri-state per decision #149;
Phase 6.2 only produces Allow + NotGranted, Denied stays reserved for v2.1
Explicit Deny without API break.
- IPermissionEvaluator.Authorize(session, operation, scope).
- PermissionTrie + PermissionTrieNode + TrieGrant. In-memory trie keyed on
the ACL scope hierarchy. CollectMatches walks Cluster → Namespace →
UnsArea → UnsLine → Equipment → Tag (or → FolderSegment(s) → Tag on
Galaxy). Pure additive union — matches that share an LDAP group with the
session contribute flags; OR across levels.
- PermissionTrieBuilder static factory. Build(clusterId, generationId, rows,
scopePaths?) returns a trie for one generation. Cross-cluster rows are
filtered out so the trie is cluster-coherent. Stream C follow-up wires a
real scopePaths lookup from the live DB; tests supply hand-built paths.
- PermissionTrieCache — process-singleton, keyed on (ClusterId, GenerationId).
Install(trie) adds a generation + promotes to "current" when the id is
highest-known (handles out-of-order installs gracefully). Prior generations
retained so an in-flight request against a prior trie still succeeds; GC
via Prune(cluster, keepLatest).
- UserAuthorizationState — per-session cache of resolved LDAP groups +
AuthGenerationId + MembershipVersion + MembershipResolvedUtc. Bounded by
MembershipFreshnessInterval (default 15 min per decision #151) +
AuthCacheMaxStaleness (default 5 min per decision #152).
- TriePermissionEvaluator — default IPermissionEvaluator. Fails closed on
stale sessions (IsStale check short-circuits to NotGranted), on cross-
cluster requests, on empty trie cache. Maps OpcUaOperation → NodePermissions
via MapOperationToPermission (total — every enum value has a mapping; tested).
Tests (27 new, all pass):
- PermissionTrieTests (7): cluster-level grant cascades to every tag;
equipment-level grant doesn't leak to sibling equipment; multi-group union
ORs flags; no-matching-group returns empty; Galaxy folder-segment grant
doesn't leak to sibling folder; cross-cluster rows don't land in this
cluster's trie; build is idempotent (B.6 invariants).
- TriePermissionEvaluatorTests (8): allow when flag matches; NotGranted when
no matching group; NotGranted when flags insufficient; HistoryRead requires
its own bit (decision-level requirement); cross-cluster session denied;
stale session fails closed; no cached trie denied; MapOperationToPermission
is total across every OpcUaOperation.
- PermissionTrieCacheTests (8): empty cache returns null; install-then-get
round-trips; new generation becomes current; out-of-order install doesn't
downgrade current; invalidate drops one cluster; prune retains most recent;
prune no-op when fewer than keep; cluster isolation.
- UserAuthorizationStateTests (4): fresh is not stale; IsStale after 5 min
default; NeedsRefresh true between freshness + staleness windows.
Full solution dotnet test: 1078 passing (baseline 906, Phase 6.1 = 1042,
Phase 6.2 Stream A = +9, Stream B = +27 = 1078). Pre-existing Client.CLI
Subscribe flake unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.6 KiB
C#
158 lines
5.6 KiB
C#
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;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class PermissionTrieTests
|
|
{
|
|
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
|
|
new()
|
|
{
|
|
NodeAclRowId = Guid.NewGuid(),
|
|
NodeAclId = $"acl-{Guid.NewGuid():N}",
|
|
GenerationId = 1,
|
|
ClusterId = clusterId,
|
|
LdapGroup = group,
|
|
ScopeKind = scope,
|
|
ScopeId = scopeId,
|
|
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,
|
|
};
|
|
|
|
private static NodeScope GalaxyTag(string cluster, string ns, string[] folders, string tag) =>
|
|
new()
|
|
{
|
|
ClusterId = cluster,
|
|
NamespaceId = ns,
|
|
FolderSegments = folders,
|
|
TagId = tag,
|
|
Kind = NodeHierarchyKind.SystemPlatform,
|
|
};
|
|
|
|
[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 matches = trie.CollectMatches(
|
|
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
|
["cn=ops"]);
|
|
|
|
matches.Count.ShouldBe(1);
|
|
matches[0].PermissionFlags.ShouldBe(NodePermissions.Read);
|
|
matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
|
|
}
|
|
|
|
[Fact]
|
|
public void EquipmentScope_DoesNotLeak_ToSibling()
|
|
{
|
|
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["eq-A"] = new(new[] { "ns", "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"]);
|
|
|
|
matchA.Count.ShouldBe(1);
|
|
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
|
|
}
|
|
|
|
[Fact]
|
|
public void MultiGroup_Union_OrsPermissionFlags()
|
|
{
|
|
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 matches = trie.CollectMatches(
|
|
EquipmentTag("c1", "ns", "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);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoMatchingGroup_ReturnsEmpty()
|
|
{
|
|
var rows = new[] { Row("cn=different", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
|
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
|
|
|
var matches = trie.CollectMatches(
|
|
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
|
["cn=ops"]);
|
|
|
|
matches.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Galaxy_FolderSegment_Grant_DoesNotLeak_To_Sibling_Folder()
|
|
{
|
|
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["folder-A"] = new(new[] { "ns-gal", "folder-A" }),
|
|
};
|
|
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "folder-A", NodePermissions.Read) };
|
|
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
|
|
|
var matchA = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-A"], "tag1"), ["cn=ops"]);
|
|
var matchB = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-B"], "tag1"), ["cn=ops"]);
|
|
|
|
matchA.Count.ShouldBe(1);
|
|
matchB.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
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 matches = trie.CollectMatches(
|
|
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
|
["cn=ops"]);
|
|
|
|
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_IsIdempotent()
|
|
{
|
|
var rows = new[]
|
|
{
|
|
Row("cn=a", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
|
Row("cn=b", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
|
};
|
|
|
|
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows);
|
|
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows);
|
|
|
|
trie1.Root.Grants.Count.ShouldBe(trie2.Root.Grants.Count);
|
|
trie1.ClusterId.ShouldBe(trie2.ClusterId);
|
|
trie1.GenerationId.ShouldBe(trie2.GenerationId);
|
|
}
|
|
}
|