Phase 6.2 Stream B — permission-trie evaluator in Core.Authorization
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>
This commit is contained in:
59
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every OPC UA operation surface the Phase 6.2 authorization evaluator gates, per
|
||||||
|
/// <c>docs/v2/implementation/phase-6-2-authorization-runtime.md</c> §Stream C and
|
||||||
|
/// decision #143. The evaluator maps each operation onto the corresponding
|
||||||
|
/// <c>NodePermissions</c> bit(s) to decide whether the calling session is allowed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Write is split out into <see cref="WriteOperate"/> / <see cref="WriteTune"/> /
|
||||||
|
/// <see cref="WriteConfigure"/> because the underlying driver-reported
|
||||||
|
/// <see cref="SecurityClassification"/> already carries that distinction — the
|
||||||
|
/// evaluator maps the requested tag's security class to the matching operation value
|
||||||
|
/// before checking the permission bit.
|
||||||
|
/// </remarks>
|
||||||
|
public enum OpcUaOperation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <c>Browse</c> + <c>TranslateBrowsePathsToNodeIds</c>. Ancestor visibility implied
|
||||||
|
/// when any descendant has a grant; denied ancestors filter from browse results.
|
||||||
|
/// </summary>
|
||||||
|
Browse,
|
||||||
|
|
||||||
|
/// <summary><c>Read</c> on a variable node.</summary>
|
||||||
|
Read,
|
||||||
|
|
||||||
|
/// <summary><c>Write</c> when the target has <see cref="SecurityClassification.Operate"/> / <see cref="SecurityClassification.FreeAccess"/>.</summary>
|
||||||
|
WriteOperate,
|
||||||
|
|
||||||
|
/// <summary><c>Write</c> when the target has <see cref="SecurityClassification.Tune"/>.</summary>
|
||||||
|
WriteTune,
|
||||||
|
|
||||||
|
/// <summary><c>Write</c> when the target has <see cref="SecurityClassification.Configure"/>.</summary>
|
||||||
|
WriteConfigure,
|
||||||
|
|
||||||
|
/// <summary><c>HistoryRead</c> — uses its own <c>NodePermissions.HistoryRead</c> bit; Read alone is NOT sufficient (decision in Phase 6.2 Compliance).</summary>
|
||||||
|
HistoryRead,
|
||||||
|
|
||||||
|
/// <summary><c>HistoryUpdate</c> — annotation / insert / delete on historian.</summary>
|
||||||
|
HistoryUpdate,
|
||||||
|
|
||||||
|
/// <summary><c>CreateMonitoredItems</c>. Per-item denial in mixed-authorization batches.</summary>
|
||||||
|
CreateMonitoredItems,
|
||||||
|
|
||||||
|
/// <summary><c>TransferSubscriptions</c>. Re-evaluates transferred items against current auth state.</summary>
|
||||||
|
TransferSubscriptions,
|
||||||
|
|
||||||
|
/// <summary><c>Call</c> on a Method node.</summary>
|
||||||
|
Call,
|
||||||
|
|
||||||
|
/// <summary>Alarm <c>Acknowledge</c>.</summary>
|
||||||
|
AlarmAcknowledge,
|
||||||
|
|
||||||
|
/// <summary>Alarm <c>Confirm</c>.</summary>
|
||||||
|
AlarmConfirm,
|
||||||
|
|
||||||
|
/// <summary>Alarm <c>Shelve</c> / <c>Unshelve</c>.</summary>
|
||||||
|
AlarmShelve,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tri-state result of an <see cref="IPermissionEvaluator.Authorize"/> call, per decision
|
||||||
|
/// #149. Phase 6.2 only produces <see cref="AuthorizationVerdict.Allow"/> and
|
||||||
|
/// <see cref="AuthorizationVerdict.NotGranted"/>; the <see cref="AuthorizationVerdict.Denied"/>
|
||||||
|
/// variant exists in the model so v2.1 Explicit Deny lands without an API break. Provenance
|
||||||
|
/// carries the matched grants (or empty when not granted) for audit + the Admin UI "Probe
|
||||||
|
/// this permission" diagnostic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuthorizationDecision(
|
||||||
|
AuthorizationVerdict Verdict,
|
||||||
|
IReadOnlyList<MatchedGrant> Provenance)
|
||||||
|
{
|
||||||
|
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
|
||||||
|
|
||||||
|
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
|
||||||
|
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
|
||||||
|
|
||||||
|
/// <summary>Allow with the list of grants that matched.</summary>
|
||||||
|
public static AuthorizationDecision Allowed(IReadOnlyList<MatchedGrant> provenance)
|
||||||
|
=> new(AuthorizationVerdict.Allow, provenance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Three-valued authorization outcome.</summary>
|
||||||
|
public enum AuthorizationVerdict
|
||||||
|
{
|
||||||
|
/// <summary>At least one grant matches the requested (operation, scope) pair.</summary>
|
||||||
|
Allow,
|
||||||
|
|
||||||
|
/// <summary>No grant matches. Phase 6.2 default — treated as deny at the OPC UA surface.</summary>
|
||||||
|
NotGranted,
|
||||||
|
|
||||||
|
/// <summary>Explicit deny grant matched. Reserved for v2.1; never produced by Phase 6.2.</summary>
|
||||||
|
Denied,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One grant that contributed to an Allow verdict — for audit / UI diagnostics.</summary>
|
||||||
|
/// <param name="LdapGroup">LDAP group the matched grant belongs to.</param>
|
||||||
|
/// <param name="Scope">Where in the hierarchy the grant was anchored.</param>
|
||||||
|
/// <param name="PermissionFlags">The bitmask the grant contributed.</param>
|
||||||
|
public sealed record MatchedGrant(
|
||||||
|
string LdapGroup,
|
||||||
|
NodeAclScopeKind Scope,
|
||||||
|
NodePermissions PermissionFlags);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates whether a session is authorized to perform an OPC UA <see cref="OpcUaOperation"/>
|
||||||
|
/// on the node addressed by a <see cref="NodeScope"/>. Phase 6.2 Stream B central surface.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Data-plane only. Reads <c>NodeAcl</c> rows joined against the session's resolved LDAP
|
||||||
|
/// groups (via <see cref="UserAuthorizationState"/>). Must not depend on
|
||||||
|
/// <c>LdapGroupRoleMapping</c> (control-plane) per decision #150.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IPermissionEvaluator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Authorize the requested operation for the session. Callers (<c>DriverNodeManager</c>
|
||||||
|
/// Read / Write / HistoryRead / Subscribe / Browse / Call dispatch) map their native
|
||||||
|
/// failure to <c>BadUserAccessDenied</c> per OPC UA Part 4 when the result is not
|
||||||
|
/// <see cref="AuthorizationVerdict.Allow"/>.
|
||||||
|
/// </summary>
|
||||||
|
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
|
||||||
|
}
|
||||||
58
src/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs
Normal file
58
src/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Address of a node in the 6-level scope hierarchy the Phase 6.2 evaluator walks.
|
||||||
|
/// Assembled by the dispatch layer from the node's namespace + UNS path + tag; passed
|
||||||
|
/// to <see cref="IPermissionEvaluator"/> which walks the matching trie path.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Per decision #129 and the Phase 6.2 Stream B plan the hierarchy is
|
||||||
|
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> for UNS
|
||||||
|
/// (Equipment-kind) namespaces. Galaxy (SystemPlatform-kind) namespaces instead use
|
||||||
|
/// <c>Cluster → Namespace → FolderSegment(s) → Tag</c>, and each folder segment takes
|
||||||
|
/// one trie level — so a deeply-nested Galaxy folder implicitly reaches the same
|
||||||
|
/// depth as a full UNS path.</para>
|
||||||
|
///
|
||||||
|
/// <para>Unset mid-path levels (e.g. a Cluster-scoped request with no UnsArea) leave
|
||||||
|
/// the corresponding id <c>null</c>. The evaluator walks as far as the scope goes +
|
||||||
|
/// stops at the first null.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record NodeScope
|
||||||
|
{
|
||||||
|
/// <summary>Cluster the node belongs to. Required.</summary>
|
||||||
|
public required string ClusterId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Namespace within the cluster. Null is not allowed for a request against a real node.</summary>
|
||||||
|
public string? NamespaceId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>For Equipment-kind namespaces: UNS area (e.g. "warsaw-west"). Null on Galaxy.</summary>
|
||||||
|
public string? UnsAreaId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>For Equipment-kind namespaces: UNS line below the area. Null on Galaxy.</summary>
|
||||||
|
public string? UnsLineId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>For Equipment-kind namespaces: equipment row below the line. Null on Galaxy.</summary>
|
||||||
|
public string? EquipmentId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For Galaxy (SystemPlatform-kind) namespaces only: the folder path segments from
|
||||||
|
/// namespace root to the target tag, in order. Empty on Equipment namespaces.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> FolderSegments { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Target tag id when the scope addresses a specific tag; null for folder / equipment-level scopes.</summary>
|
||||||
|
public string? TagId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Which hierarchy applies — Equipment-kind (UNS) or SystemPlatform-kind (Galaxy).</summary>
|
||||||
|
public required NodeHierarchyKind Kind { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Selector between the two scope-hierarchy shapes.</summary>
|
||||||
|
public enum NodeHierarchyKind
|
||||||
|
{
|
||||||
|
/// <summary><c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> — UNS / Equipment kind.</summary>
|
||||||
|
Equipment,
|
||||||
|
|
||||||
|
/// <summary><c>Cluster → Namespace → FolderSegment(s) → Tag</c> — Galaxy / SystemPlatform kind.</summary>
|
||||||
|
SystemPlatform,
|
||||||
|
}
|
||||||
125
src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs
Normal file
125
src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="PermissionTrie"/> from a set of <see cref="NodeAcl"/> rows anchored
|
||||||
|
/// in one generation. The trie is keyed on the rows' scope hierarchy — rows with
|
||||||
|
/// <see cref="NodeAclScopeKind.Cluster"/> land at the trie root, rows with
|
||||||
|
/// <see cref="NodeAclScopeKind.Tag"/> land at a leaf, etc.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Intended to be called by <see cref="PermissionTrieCache"/> once per published
|
||||||
|
/// generation; the resulting trie is immutable for the life of the cache entry. Idempotent —
|
||||||
|
/// two builds from the same rows produce equal tries (grant lists may be in insertion order;
|
||||||
|
/// evaluators don't depend on order).</para>
|
||||||
|
///
|
||||||
|
/// <para>The builder deliberately does not know about the node-row metadata the trie path
|
||||||
|
/// will be walked with. The caller assembles <see cref="NodeScope"/> values from the live
|
||||||
|
/// config (UnsArea parent of UnsLine, etc.); this class only honors the <c>ScopeId</c>
|
||||||
|
/// each row carries.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class PermissionTrieBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build a trie for one cluster/generation from the supplied rows. The caller is
|
||||||
|
/// responsible for pre-filtering rows to the target generation + cluster.
|
||||||
|
/// </summary>
|
||||||
|
public static PermissionTrie Build(
|
||||||
|
string clusterId,
|
||||||
|
long generationId,
|
||||||
|
IReadOnlyList<NodeAcl> rows,
|
||||||
|
IReadOnlyDictionary<string, NodeAclPath>? scopePaths = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
|
||||||
|
var trie = new PermissionTrie { ClusterId = clusterId, GenerationId = generationId };
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (!string.Equals(row.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
var grant = new TrieGrant(row.LdapGroup, row.PermissionFlags);
|
||||||
|
|
||||||
|
var node = row.ScopeKind switch
|
||||||
|
{
|
||||||
|
NodeAclScopeKind.Cluster => trie.Root,
|
||||||
|
_ => Descend(trie.Root, row, scopePaths),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node is not null)
|
||||||
|
node.Grants.Add(grant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trie;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PermissionTrieNode? Descend(PermissionTrieNode root, NodeAcl row, IReadOnlyDictionary<string, NodeAclPath>? scopePaths)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(row.ScopeId)) return null;
|
||||||
|
|
||||||
|
// For sub-cluster scopes the caller supplies a path lookup so we know the containing
|
||||||
|
// namespace / UnsArea / UnsLine ids. Without a path lookup we fall back to putting the
|
||||||
|
// row directly under the root using its ScopeId — works for deterministic tests, not
|
||||||
|
// for production where the hierarchy must be honored.
|
||||||
|
if (scopePaths is null || !scopePaths.TryGetValue(row.ScopeId, out var path))
|
||||||
|
{
|
||||||
|
return EnsureChild(root, row.ScopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var node = root;
|
||||||
|
foreach (var segment in path.Segments)
|
||||||
|
node = EnsureChild(node, segment);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PermissionTrieNode EnsureChild(PermissionTrieNode parent, string key)
|
||||||
|
{
|
||||||
|
if (!parent.Children.TryGetValue(key, out var child))
|
||||||
|
{
|
||||||
|
child = new PermissionTrieNode();
|
||||||
|
parent.Children[key] = child;
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ordered list of trie-path segments from root to the target node. Supplied to
|
||||||
|
/// <see cref="PermissionTrieBuilder.Build"/> so the builder knows where a
|
||||||
|
/// <see cref="NodeAclScopeKind.UnsLine"/>-scoped row sits in the hierarchy.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Segments">
|
||||||
|
/// Namespace id, then (for Equipment kind) UnsAreaId / UnsLineId / EquipmentId / TagId as
|
||||||
|
/// applicable; or (for SystemPlatform kind) NamespaceId / FolderSegment / .../TagId.
|
||||||
|
/// </param>
|
||||||
|
public sealed record NodeAclPath(IReadOnlyList<string> Segments);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process-singleton cache of <see cref="PermissionTrie"/> instances keyed on
|
||||||
|
/// <c>(ClusterId, GenerationId)</c>. Hot-path evaluation reads
|
||||||
|
/// <see cref="GetTrie(string)"/> without awaiting DB access; the cache is populated
|
||||||
|
/// out-of-band on publish + on first reference via
|
||||||
|
/// <see cref="Install(PermissionTrie)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Per decision #148 and Phase 6.2 Stream B.4 the cache is generation-sealed: once a
|
||||||
|
/// trie is installed for <c>(ClusterId, GenerationId)</c> the entry is immutable. When a
|
||||||
|
/// new generation publishes, the caller calls <see cref="Install"/> with the new trie
|
||||||
|
/// + the cache atomically updates its "current generation" pointer for that cluster.
|
||||||
|
/// Older generations are retained so an in-flight request evaluating the prior generation
|
||||||
|
/// still succeeds — GC via <see cref="Prune(string, int)"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class PermissionTrieCache
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, ClusterEntry> _byCluster =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Install a trie for a cluster + make it the current generation.</summary>
|
||||||
|
public void Install(PermissionTrie trie)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(trie);
|
||||||
|
_byCluster.AddOrUpdate(trie.ClusterId,
|
||||||
|
_ => ClusterEntry.FromSingle(trie),
|
||||||
|
(_, existing) => existing.WithAdditional(trie));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get the current-generation trie for a cluster; null when nothing installed.</summary>
|
||||||
|
public PermissionTrie? GetTrie(string clusterId)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||||
|
return _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get a specific (cluster, generation) trie; null if that pair isn't cached.</summary>
|
||||||
|
public PermissionTrie? GetTrie(string clusterId, long generationId)
|
||||||
|
{
|
||||||
|
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
|
||||||
|
return entry.Tries.TryGetValue(generationId, out var trie) ? trie : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The generation id the <see cref="GetTrie(string)"/> shortcut currently serves for a cluster.</summary>
|
||||||
|
public long? CurrentGenerationId(string clusterId)
|
||||||
|
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
|
||||||
|
|
||||||
|
/// <summary>Drop every cached trie for one cluster.</summary>
|
||||||
|
public void Invalidate(string clusterId) => _byCluster.TryRemove(clusterId, out _);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retain only the most-recent <paramref name="keepLatest"/> generations for a cluster.
|
||||||
|
/// No-op when there's nothing to drop.
|
||||||
|
/// </summary>
|
||||||
|
public void Prune(string clusterId, int keepLatest = 3)
|
||||||
|
{
|
||||||
|
if (keepLatest < 1) throw new ArgumentOutOfRangeException(nameof(keepLatest), keepLatest, "keepLatest must be >= 1");
|
||||||
|
if (!_byCluster.TryGetValue(clusterId, out var entry)) return;
|
||||||
|
|
||||||
|
if (entry.Tries.Count <= keepLatest) return;
|
||||||
|
var keep = entry.Tries
|
||||||
|
.OrderByDescending(kvp => kvp.Key)
|
||||||
|
.Take(keepLatest)
|
||||||
|
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||||
|
_byCluster[clusterId] = new ClusterEntry(entry.Current, keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Diagnostics counter: number of cached (cluster, generation) tries.</summary>
|
||||||
|
public int CachedTrieCount => _byCluster.Values.Sum(e => e.Tries.Count);
|
||||||
|
|
||||||
|
private sealed record ClusterEntry(PermissionTrie Current, IReadOnlyDictionary<long, PermissionTrie> Tries)
|
||||||
|
{
|
||||||
|
public static ClusterEntry FromSingle(PermissionTrie trie) =>
|
||||||
|
new(trie, new Dictionary<long, PermissionTrie> { [trie.GenerationId] = trie });
|
||||||
|
|
||||||
|
public ClusterEntry WithAdditional(PermissionTrie trie)
|
||||||
|
{
|
||||||
|
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
|
||||||
|
// The highest generation wins as "current" — handles out-of-order installs.
|
||||||
|
var current = trie.GenerationId >= Current.GenerationId ? trie : Current;
|
||||||
|
return new ClusterEntry(current, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IPermissionEvaluator"/> implementation. Resolves the
|
||||||
|
/// <see cref="PermissionTrie"/> for the session's cluster (via
|
||||||
|
/// <see cref="PermissionTrieCache"/>), walks it collecting matched grants, OR-s the
|
||||||
|
/// permission flags, and maps against the operation-specific required permission.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TriePermissionEvaluator : IPermissionEvaluator
|
||||||
|
{
|
||||||
|
private readonly PermissionTrieCache _cache;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public TriePermissionEvaluator(PermissionTrieCache cache, TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(cache);
|
||||||
|
_cache = cache;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
ArgumentNullException.ThrowIfNull(scope);
|
||||||
|
|
||||||
|
// Decision #152 — beyond the staleness ceiling every call fails closed regardless of
|
||||||
|
// cache warmth elsewhere in the process.
|
||||||
|
if (session.IsStale(_timeProvider.GetUtcNow().UtcDateTime))
|
||||||
|
return AuthorizationDecision.NotGranted();
|
||||||
|
|
||||||
|
if (!string.Equals(session.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return AuthorizationDecision.NotGranted();
|
||||||
|
|
||||||
|
var trie = _cache.GetTrie(scope.ClusterId);
|
||||||
|
if (trie is null) return AuthorizationDecision.NotGranted();
|
||||||
|
|
||||||
|
var matches = trie.CollectMatches(scope, session.LdapGroups);
|
||||||
|
if (matches.Count == 0) return AuthorizationDecision.NotGranted();
|
||||||
|
|
||||||
|
var required = MapOperationToPermission(operation);
|
||||||
|
var granted = NodePermissions.None;
|
||||||
|
foreach (var m in matches) granted |= m.PermissionFlags;
|
||||||
|
|
||||||
|
return (granted & required) == required
|
||||||
|
? AuthorizationDecision.Allowed(matches)
|
||||||
|
: AuthorizationDecision.NotGranted();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps each <see cref="OpcUaOperation"/> to the <see cref="NodePermissions"/> bit required to grant it.</summary>
|
||||||
|
public static NodePermissions MapOperationToPermission(OpcUaOperation op) => op switch
|
||||||
|
{
|
||||||
|
OpcUaOperation.Browse => NodePermissions.Browse,
|
||||||
|
OpcUaOperation.Read => NodePermissions.Read,
|
||||||
|
OpcUaOperation.WriteOperate => NodePermissions.WriteOperate,
|
||||||
|
OpcUaOperation.WriteTune => NodePermissions.WriteTune,
|
||||||
|
OpcUaOperation.WriteConfigure => NodePermissions.WriteConfigure,
|
||||||
|
OpcUaOperation.HistoryRead => NodePermissions.HistoryRead,
|
||||||
|
OpcUaOperation.HistoryUpdate => NodePermissions.HistoryRead, // HistoryUpdate bit not yet in NodePermissions; TODO Stream C follow-up
|
||||||
|
OpcUaOperation.CreateMonitoredItems => NodePermissions.Subscribe,
|
||||||
|
OpcUaOperation.TransferSubscriptions=> NodePermissions.Subscribe,
|
||||||
|
OpcUaOperation.Call => NodePermissions.MethodCall,
|
||||||
|
OpcUaOperation.AlarmAcknowledge => NodePermissions.AlarmAcknowledge,
|
||||||
|
OpcUaOperation.AlarmConfirm => NodePermissions.AlarmConfirm,
|
||||||
|
OpcUaOperation.AlarmShelve => NodePermissions.AlarmShelve,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(op), op, $"No permission mapping defined for operation {op}."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-session authorization state cached on the OPC UA session object + keyed on the
|
||||||
|
/// session id. Captures the LDAP group memberships resolved at sign-in, the generation
|
||||||
|
/// the membership was resolved against, and the bounded freshness window.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Per decision #151 the membership is bounded by <see cref="MembershipFreshnessInterval"/>
|
||||||
|
/// (default 15 min). After that, the next hot-path authz call re-resolves LDAP group
|
||||||
|
/// memberships; failure to re-resolve (LDAP unreachable) flips the session to fail-closed
|
||||||
|
/// until a refresh succeeds.
|
||||||
|
///
|
||||||
|
/// Per decision #152 <see cref="AuthCacheMaxStaleness"/> (default 5 min) is separate from
|
||||||
|
/// Phase 6.1's availability-oriented 24h cache — beyond this window the evaluator returns
|
||||||
|
/// <see cref="AuthorizationVerdict.NotGranted"/> regardless of config-cache warmth.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record UserAuthorizationState
|
||||||
|
{
|
||||||
|
/// <summary>Opaque session id (reuse OPC UA session handle when possible).</summary>
|
||||||
|
public required string SessionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Cluster the session is scoped to — every request targets nodes in this cluster.</summary>
|
||||||
|
public required string ClusterId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LDAP groups the user is a member of as resolved at sign-in / last membership refresh.
|
||||||
|
/// Case comparison is handled downstream by the evaluator (OrdinalIgnoreCase).
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyList<string> LdapGroups { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Timestamp when <see cref="LdapGroups"/> was last resolved from the directory.</summary>
|
||||||
|
public required DateTime MembershipResolvedUtc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trie generation the session is currently bound to. When
|
||||||
|
/// <see cref="PermissionTrieCache"/> moves to a new generation, the session's
|
||||||
|
/// <c>(AuthGenerationId, MembershipVersion)</c> stamp no longer matches its
|
||||||
|
/// MonitoredItems and they re-evaluate on next publish (decision #153).
|
||||||
|
/// </summary>
|
||||||
|
public required long AuthGenerationId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Monotonic counter incremented every time membership is re-resolved. Combined with
|
||||||
|
/// <see cref="AuthGenerationId"/> into the subscription stamp per decision #153.
|
||||||
|
/// </summary>
|
||||||
|
public required long MembershipVersion { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Bounded membership freshness window; past this the next authz call refreshes.</summary>
|
||||||
|
public TimeSpan MembershipFreshnessInterval { get; init; } = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
/// <summary>Hard staleness ceiling — beyond this, the evaluator fails closed.</summary>
|
||||||
|
public TimeSpan AuthCacheMaxStaleness { get; init; } = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="utcNow"/> - <see cref="MembershipResolvedUtc"/> exceeds
|
||||||
|
/// <see cref="AuthCacheMaxStaleness"/>. The evaluator short-circuits to NotGranted
|
||||||
|
/// whenever this is true.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsStale(DateTime utcNow) => utcNow - MembershipResolvedUtc > AuthCacheMaxStaleness;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when membership is past its freshness interval but still within the staleness
|
||||||
|
/// ceiling — a signal to the caller to kick off an async refresh, while the current
|
||||||
|
/// call still evaluates against the cached memberships.
|
||||||
|
/// </summary>
|
||||||
|
public bool NeedsRefresh(DateTime utcNow) =>
|
||||||
|
!IsStale(utcNow) && utcNow - MembershipResolvedUtc > MembershipFreshnessInterval;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class PermissionTrieCacheTests
|
||||||
|
{
|
||||||
|
private static PermissionTrie Trie(string cluster, long generation) => new()
|
||||||
|
{
|
||||||
|
ClusterId = cluster,
|
||||||
|
GenerationId = generation,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetTrie_Empty_ReturnsNull()
|
||||||
|
{
|
||||||
|
new PermissionTrieCache().GetTrie("c1").ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Install_ThenGet_RoundTrips()
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
cache.Install(Trie("c1", 5));
|
||||||
|
|
||||||
|
cache.GetTrie("c1")!.GenerationId.ShouldBe(5);
|
||||||
|
cache.CurrentGenerationId("c1").ShouldBe(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewGeneration_BecomesCurrent()
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
cache.Install(Trie("c1", 1));
|
||||||
|
cache.Install(Trie("c1", 2));
|
||||||
|
|
||||||
|
cache.CurrentGenerationId("c1").ShouldBe(2);
|
||||||
|
cache.GetTrie("c1", 1).ShouldNotBeNull("prior generation retained for in-flight requests");
|
||||||
|
cache.GetTrie("c1", 2).ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OutOfOrder_Install_DoesNotDowngrade_Current()
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
cache.Install(Trie("c1", 3));
|
||||||
|
cache.Install(Trie("c1", 1)); // late-arriving older generation
|
||||||
|
|
||||||
|
cache.CurrentGenerationId("c1").ShouldBe(3, "older generation must not become current");
|
||||||
|
cache.GetTrie("c1", 1).ShouldNotBeNull("but older is still retrievable by explicit lookup");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Invalidate_DropsCluster()
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
cache.Install(Trie("c1", 1));
|
||||||
|
cache.Install(Trie("c2", 1));
|
||||||
|
|
||||||
|
cache.Invalidate("c1");
|
||||||
|
|
||||||
|
cache.GetTrie("c1").ShouldBeNull();
|
||||||
|
cache.GetTrie("c2").ShouldNotBeNull("sibling cluster unaffected");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prune_RetainsMostRecent()
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
for (var g = 1L; g <= 5; g++) cache.Install(Trie("c1", g));
|
||||||
|
|
||||||
|
cache.Prune("c1", keepLatest: 2);
|
||||||
|
|
||||||
|
cache.GetTrie("c1", 5).ShouldNotBeNull();
|
||||||
|
cache.GetTrie("c1", 4).ShouldNotBeNull();
|
||||||
|
cache.GetTrie("c1", 3).ShouldBeNull();
|
||||||
|
cache.GetTrie("c1", 1).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prune_LessThanKeep_IsNoOp()
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
cache.Install(Trie("c1", 1));
|
||||||
|
cache.Install(Trie("c1", 2));
|
||||||
|
|
||||||
|
cache.Prune("c1", keepLatest: 10);
|
||||||
|
|
||||||
|
cache.CachedTrieCount.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClusterIsolation()
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
cache.Install(Trie("c1", 1));
|
||||||
|
cache.Install(Trie("c2", 9));
|
||||||
|
|
||||||
|
cache.CurrentGenerationId("c1").ShouldBe(1);
|
||||||
|
cache.CurrentGenerationId("c2").ShouldBe(9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class TriePermissionEvaluatorTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
private readonly FakeTimeProvider _time = new();
|
||||||
|
|
||||||
|
private sealed class FakeTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
public DateTime Utc { get; set; } = Now;
|
||||||
|
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
NodeAclRowId = Guid.NewGuid(),
|
||||||
|
NodeAclId = $"acl-{Guid.NewGuid():N}",
|
||||||
|
GenerationId = 1,
|
||||||
|
ClusterId = "c1",
|
||||||
|
LdapGroup = group,
|
||||||
|
ScopeKind = scope,
|
||||||
|
ScopeId = scopeId,
|
||||||
|
PermissionFlags = flags,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static UserAuthorizationState Session(string[] groups, DateTime? resolvedUtc = null, string clusterId = "c1") =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
SessionId = "sess",
|
||||||
|
ClusterId = clusterId,
|
||||||
|
LdapGroups = groups,
|
||||||
|
MembershipResolvedUtc = resolvedUtc ?? Now,
|
||||||
|
AuthGenerationId = 1,
|
||||||
|
MembershipVersion = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static NodeScope Scope(string cluster = "c1") =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ClusterId = cluster,
|
||||||
|
NamespaceId = "ns",
|
||||||
|
UnsAreaId = "area",
|
||||||
|
UnsLineId = "line",
|
||||||
|
EquipmentId = "eq",
|
||||||
|
TagId = "tag",
|
||||||
|
Kind = NodeHierarchyKind.Equipment,
|
||||||
|
};
|
||||||
|
|
||||||
|
private TriePermissionEvaluator MakeEvaluator(NodeAcl[] rows)
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
|
||||||
|
return new TriePermissionEvaluator(cache, _time);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Allow_When_RequiredFlag_Matched()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||||
|
|
||||||
|
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||||
|
|
||||||
|
decision.Verdict.ShouldBe(AuthorizationVerdict.Allow);
|
||||||
|
decision.Provenance.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotGranted_When_NoMatchingGroup()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||||
|
|
||||||
|
var decision = evaluator.Authorize(Session(["cn=unrelated"]), OpcUaOperation.Read, Scope());
|
||||||
|
|
||||||
|
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||||
|
decision.Provenance.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotGranted_When_FlagsInsufficient()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||||
|
|
||||||
|
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.WriteOperate, Scope());
|
||||||
|
|
||||||
|
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HistoryRead_Requires_Its_Own_Bit()
|
||||||
|
{
|
||||||
|
// User has Read but not HistoryRead
|
||||||
|
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||||
|
|
||||||
|
var liveRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||||
|
var historyRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.HistoryRead, Scope());
|
||||||
|
|
||||||
|
liveRead.IsAllowed.ShouldBeTrue();
|
||||||
|
historyRead.IsAllowed.ShouldBeFalse("HistoryRead uses its own NodePermissions flag, not Read");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CrossCluster_Session_Denied()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||||
|
var otherSession = Session(["cn=ops"], clusterId: "c-other");
|
||||||
|
|
||||||
|
var decision = evaluator.Authorize(otherSession, OpcUaOperation.Read, Scope(cluster: "c1"));
|
||||||
|
|
||||||
|
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StaleSession_FailsClosed()
|
||||||
|
{
|
||||||
|
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||||
|
var session = Session(["cn=ops"], resolvedUtc: Now);
|
||||||
|
_time.Utc = Now.AddMinutes(10); // well past the 5-min AuthCacheMaxStaleness default
|
||||||
|
|
||||||
|
var decision = evaluator.Authorize(session, OpcUaOperation.Read, Scope());
|
||||||
|
|
||||||
|
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoCachedTrie_ForCluster_Denied()
|
||||||
|
{
|
||||||
|
var cache = new PermissionTrieCache(); // empty cache
|
||||||
|
var evaluator = new TriePermissionEvaluator(cache, _time);
|
||||||
|
|
||||||
|
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||||
|
|
||||||
|
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OperationToPermission_Mapping_IsTotal()
|
||||||
|
{
|
||||||
|
foreach (var op in Enum.GetValues<OpcUaOperation>())
|
||||||
|
{
|
||||||
|
// Must not throw — every OpcUaOperation needs a mapping or the compliance-check
|
||||||
|
// "every operation wired" fails.
|
||||||
|
TriePermissionEvaluator.MapOperationToPermission(op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class UserAuthorizationStateTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static UserAuthorizationState Fresh(DateTime resolved) => new()
|
||||||
|
{
|
||||||
|
SessionId = "s",
|
||||||
|
ClusterId = "c1",
|
||||||
|
LdapGroups = ["cn=ops"],
|
||||||
|
MembershipResolvedUtc = resolved,
|
||||||
|
AuthGenerationId = 1,
|
||||||
|
MembershipVersion = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FreshlyResolved_Is_NotStale_NorNeedsRefresh()
|
||||||
|
{
|
||||||
|
var session = Fresh(Now);
|
||||||
|
|
||||||
|
session.IsStale(Now.AddMinutes(1)).ShouldBeFalse();
|
||||||
|
session.NeedsRefresh(Now.AddMinutes(1)).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeedsRefresh_FiresAfter_FreshnessInterval()
|
||||||
|
{
|
||||||
|
var session = Fresh(Now);
|
||||||
|
|
||||||
|
session.NeedsRefresh(Now.AddMinutes(16)).ShouldBeFalse("past freshness but also past the 5-min staleness ceiling — should be Stale, not NeedsRefresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeedsRefresh_TrueBetween_Freshness_And_Staleness_Windows()
|
||||||
|
{
|
||||||
|
// Custom: freshness=2 min, staleness=10 min → between 2 and 10 min NeedsRefresh fires.
|
||||||
|
var session = Fresh(Now) with
|
||||||
|
{
|
||||||
|
MembershipFreshnessInterval = TimeSpan.FromMinutes(2),
|
||||||
|
AuthCacheMaxStaleness = TimeSpan.FromMinutes(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
session.NeedsRefresh(Now.AddMinutes(5)).ShouldBeTrue();
|
||||||
|
session.IsStale(Now.AddMinutes(5)).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsStale_TrueAfter_StalenessWindow()
|
||||||
|
{
|
||||||
|
var session = Fresh(Now);
|
||||||
|
|
||||||
|
session.IsStale(Now.AddMinutes(6)).ShouldBeTrue("default AuthCacheMaxStaleness is 5 min");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user