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>
126 lines
5.8 KiB
C#
126 lines
5.8 KiB
C#
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
|
|
/// <summary>
|
|
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster
|
|
/// root down through namespace → UNS levels (or folder segments) → tag, OR-ing the
|
|
/// <see cref="TrieGrant.PermissionFlags"/> granted at each visited level for each of the session's
|
|
/// LDAP groups. The accumulated bitmask is compared to the permission required by the
|
|
/// requested <see cref="Abstractions.OpcUaOperation"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Per decision #129 (additive grants, no explicit Deny in v2.0) the walk is pure union:
|
|
/// encountering a grant at any level contributes its flags, never revokes them. A grant at
|
|
/// the Cluster root therefore cascades to every tag below it; a grant at a deep equipment
|
|
/// leaf is visible only on that equipment subtree.
|
|
/// </remarks>
|
|
public sealed class PermissionTrie
|
|
{
|
|
/// <summary>Cluster this trie belongs to.</summary>
|
|
public required string ClusterId { get; init; }
|
|
|
|
/// <summary>Config generation the trie was built from — used by the cache for invalidation.</summary>
|
|
public required long GenerationId { get; init; }
|
|
|
|
/// <summary>Root of the trie. Level 0 (cluster-level grants) live directly here.</summary>
|
|
public PermissionTrieNode Root { get; init; } = new();
|
|
|
|
/// <summary>
|
|
/// Walk the trie collecting grants that apply to <paramref name="scope"/> for any of the
|
|
/// session's <paramref name="ldapGroups"/>. Returns the matched-grant list; the caller
|
|
/// OR-s the flag bits to decide whether the requested permission is carried.
|
|
/// </summary>
|
|
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(scope);
|
|
ArgumentNullException.ThrowIfNull(ldapGroups);
|
|
|
|
var groups = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
if (groups.Count == 0) return [];
|
|
|
|
var matches = new List<MatchedGrant>();
|
|
|
|
// Level 0 — cluster-scoped grants.
|
|
CollectAtLevel(Root, NodeAclScopeKind.Cluster, groups, matches);
|
|
|
|
// Level 1 — namespace.
|
|
if (scope.NamespaceId is null) return matches;
|
|
if (!Root.Children.TryGetValue(scope.NamespaceId, out var ns)) return matches;
|
|
CollectAtLevel(ns, NodeAclScopeKind.Namespace, groups, matches);
|
|
|
|
// Two hierarchies diverge below the namespace.
|
|
if (scope.Kind == NodeHierarchyKind.Equipment)
|
|
WalkEquipment(ns, scope, groups, matches);
|
|
else
|
|
WalkSystemPlatform(ns, scope, groups, matches);
|
|
|
|
return matches;
|
|
}
|
|
|
|
private static void WalkEquipment(PermissionTrieNode ns, NodeScope scope, HashSet<string> groups, List<MatchedGrant> matches)
|
|
{
|
|
if (scope.UnsAreaId is null) return;
|
|
if (!ns.Children.TryGetValue(scope.UnsAreaId, out var area)) return;
|
|
CollectAtLevel(area, NodeAclScopeKind.UnsArea, groups, matches);
|
|
|
|
if (scope.UnsLineId is null) return;
|
|
if (!area.Children.TryGetValue(scope.UnsLineId, out var line)) return;
|
|
CollectAtLevel(line, NodeAclScopeKind.UnsLine, groups, matches);
|
|
|
|
if (scope.EquipmentId is null) return;
|
|
if (!line.Children.TryGetValue(scope.EquipmentId, out var eq)) return;
|
|
CollectAtLevel(eq, NodeAclScopeKind.Equipment, groups, matches);
|
|
|
|
if (scope.TagId is null) return;
|
|
if (!eq.Children.TryGetValue(scope.TagId, out var tag)) return;
|
|
CollectAtLevel(tag, NodeAclScopeKind.Tag, groups, matches);
|
|
}
|
|
|
|
private static void WalkSystemPlatform(PermissionTrieNode ns, NodeScope scope, HashSet<string> groups, List<MatchedGrant> matches)
|
|
{
|
|
// FolderSegments are nested under the namespace; each is its own trie level. Reuse the
|
|
// UnsArea scope kind for the flags — NodeAcl rows for Galaxy tags carry ScopeKind.Tag
|
|
// for leaf grants and ScopeKind.Namespace for folder-root grants; deeper folder grants
|
|
// are modeled as Equipment-level rows today since NodeAclScopeKind doesn't enumerate
|
|
// a dedicated FolderSegment kind. Future-proof TODO tracked in Stream B follow-up.
|
|
var current = ns;
|
|
foreach (var segment in scope.FolderSegments)
|
|
{
|
|
if (!current.Children.TryGetValue(segment, out var child)) return;
|
|
CollectAtLevel(child, NodeAclScopeKind.Equipment, groups, matches);
|
|
current = child;
|
|
}
|
|
|
|
if (scope.TagId is null) return;
|
|
if (!current.Children.TryGetValue(scope.TagId, out var tag)) return;
|
|
CollectAtLevel(tag, NodeAclScopeKind.Tag, groups, matches);
|
|
}
|
|
|
|
private static void CollectAtLevel(PermissionTrieNode node, NodeAclScopeKind level, HashSet<string> groups, List<MatchedGrant> matches)
|
|
{
|
|
foreach (var grant in node.Grants)
|
|
{
|
|
if (groups.Contains(grant.LdapGroup))
|
|
matches.Add(new MatchedGrant(grant.LdapGroup, level, grant.PermissionFlags));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>One node in a <see cref="PermissionTrie"/>.</summary>
|
|
public sealed class PermissionTrieNode
|
|
{
|
|
/// <summary>Grants anchored at this trie level.</summary>
|
|
public List<TrieGrant> Grants { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Children keyed by the next level's id — namespace id under cluster; UnsAreaId or
|
|
/// folder-segment name under namespace; etc. Comparer is OrdinalIgnoreCase so the walk
|
|
/// tolerates case drift between the NodeAcl row and the requested scope.
|
|
/// </summary>
|
|
public Dictionary<string, PermissionTrieNode> Children { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>Projection of a <see cref="Configuration.Entities.NodeAcl"/> row into the trie.</summary>
|
|
public sealed record TrieGrant(string LdapGroup, NodePermissions PermissionFlags);
|