Merge pull request (#85) - Phase 6.2 Stream B
This commit was merged in pull request #85.
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