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>
70 lines
3.6 KiB
C#
70 lines
3.6 KiB
C#
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;
|
|
}
|