using Microsoft.Extensions.Logging; using Opc.Ua; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; /// /// Turns an inbound OPC UA request into a node-scoped ACL decision (#520, stage 5). The one place /// the address space's meets . /// /// /// /// Dormant unless . With Security:Acl:Enabled false — /// the default — is never consulted and the node manager keeps its /// server-wide role gates. See for why the two are alternatives rather /// than a chain. /// /// /// Every failure path denies. No identity, no principal, no cluster identity, a NodeId /// whose identifier is not a string, a trie that was never installed — each returns /// BadUserAccessDenied. That is the only safe direction for a gate, but it also means a /// wiring mistake presents as "everything is denied" rather than "everything is allowed", which /// is loud. The install log at OpcUaPublish is where an operator confirms a trie exists at /// all. /// /// /// Authorization state is built per call, not per session. /// was designed for LDAP, where a directory can change group membership out of band and a /// long-lived session must re-resolve on a timer. Under the API-key model nothing can change a /// session's principals out of band — the principal comes from the key, and keys and grants both /// ride the deployment artifact — so the only real invalidation signal is the generation stamp, /// which a per-call construction picks up for free. The cost is one small record per write, alarm /// command or HistoryRead, none of which are hot. /// /// /// Stage 6's request-rate seams — Read, Browse, subscription creation — go through /// instead, which memoizes the grant walk in /// . That is a cache of the walk, not of the decision: the key's /// revocation and expiry are re-checked on every call regardless, because expiry moves with the /// clock rather than with a generation. /// /// /// and /// are vestigial here rather than load-bearing, /// and a reader should not conclude from their presence that a freshness timer is doing anything. /// They are switched off explicitly (AuthCacheMaxStaleness = TimeSpan.MaxValue) rather /// than left to never be reached — see the comment at that line. Relying on "the stamp is always /// fresh" was not safe: the stamp comes from this class's clock and the ceiling is checked against /// the evaluator's, so any divergence between the two denied every request with a verdict that /// looks exactly like "no grant matched". /// /// public sealed class DataPlaneAuthorizer { /// /// The single veto every denial returns. Logged, never explained to the client: the client gets an /// undifferentiated BadUserAccessDenied so the server cannot be used to probe which nodes /// exist or which grants a principal holds, and the operator debugging a denial reads the reason in /// the log. Same posture as the authenticator's DenyMessage. /// /// /// Shared rather than per-call, because is immutable to us. /// Verified by reflection against the pinned Opc.Ua.Core 1.5.378.106: every one of its properties /// (StatusCode, SymbolicId, NamespaceUri, LocalizedText, /// AdditionalInfo, InnerResult) has a private setter, and its only non-getter /// method — Append(StringBuilder) — formats rather than mutates, so no code outside the type /// can change an instance. The SDK itself publishes ServiceResult.Good and /// ServiceResult.Bad as shared static readonly singletons, which is its own /// endorsement of the pattern. Every call site here treats the result as an opaque value — it is /// stored into an SDK errors array or returned — and none compares by reference. /// private static readonly ServiceResult DeniedResult = new(StatusCodes.BadUserAccessDenied); private readonly IDataPlaneAuthoritySource _authority; private readonly IPermissionEvaluator _evaluator; private readonly ILogger _log; private readonly TimeProvider _time; /// Initializes a new instance of the class. /// The node's current data-plane authorization inputs. /// The permission evaluator reading the tries built alongside those inputs. /// Whether ACL evaluation decides authorization (Security:Acl:Enabled). /// The logger. /// Time provider, injected for testability. public DataPlaneAuthorizer( IDataPlaneAuthoritySource authority, IPermissionEvaluator evaluator, bool enabled, ILogger log, TimeProvider? time = null) { ArgumentNullException.ThrowIfNull(authority); ArgumentNullException.ThrowIfNull(evaluator); ArgumentNullException.ThrowIfNull(log); _authority = authority; _evaluator = evaluator; _log = log; _time = time ?? TimeProvider.System; Enabled = enabled; } /// /// True when ACL grants — rather than the server-wide role set — decide write, alarm-command and /// HistoryRead authorization. /// public bool Enabled { get; } /// The clock this gate evaluates key expiry against. /// /// Exposed so the composition root's wiring can be asserted by reference. The remarks on /// UserAuthorizationState record what a second, diverging clock costs: every request denied, /// with a verdict indistinguishable from "no grant matched". One registered singleton threaded into /// every consumer is the fix, and a test that cannot see which instance landed here cannot pin it. /// public TimeProvider Clock => _time; /// /// Memoized grant walks for the request-rate seams (#520 stage 6). See /// for what it is and is not allowed to cache. /// public SessionAclDecisionCache Decisions { get; } = new(); /// /// Authorizes on through the decision cache — /// the entry point for Read, Browse and subscription creation, which ask the same question many /// times per request. /// /// /// Identical in outcome to ; it differs only in memoizing the grant walk and /// in logging denials at Debug rather than Information. The quieter log is deliberate: a browse of a /// large folder by an ungranted principal would otherwise emit one Information line per child. The /// per-request summary the node manager logs is the operator-facing signal; this is the detail behind /// it. /// /// The role-carrying identity off the SDK context, or null when the session carries none. /// The operation being attempted. /// The target node. /// The namespace the node was resolved in. /// null to proceed; otherwise the veto to return to the SDK. public ServiceResult? AuthorizeCached( RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId, AddressSpaceRealm realm) => Evaluate(identity, operation, nodeId, realm, cached: true); /// /// Emits the one operator-facing line per gated request, summarising what a hot seam withheld. /// /// /// This exists because the per-node denials behind it are logged at Debug: a Browse of a large /// folder by an ungranted principal would otherwise emit one Information line per child, and an /// operator would be reading a flood rather than a fact. It lives on the authorizer rather than the /// node manager purely because the node manager is a CustomNodeManager2 and carries no /// ILogger — everything it logs itself goes through the SDK's obsolete static trace. /// /// The service being filtered, e.g. Browse. /// The calling principal, or null when the session resolved none. /// How many nodes or references were withheld. /// How many were considered. public void LogFiltered(string operation, string? principal, int withheld, int total) { if (withheld == 0) return; _log.LogInformation( "AclGate: {Operation} withheld {Withheld} of {Total} node(s) for principal {Principal}", operation, withheld, total, principal ?? "(none)"); } /// Whether may perform , as a bool. /// The role-carrying identity off the SDK context. /// The operation being attempted. /// The target node. /// The namespace the node was resolved in. /// when the operation is permitted. public bool IsAllowedCached( RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId, AddressSpaceRealm realm) => AuthorizeCached(identity, operation, nodeId, realm) is null; /// /// Authorizes on for the session behind /// . /// /// The role-carrying identity off the SDK context, or null when the session /// carries none. /// The operation being attempted. /// The target node. /// Which namespace the node was resolved in — passed rather than re-derived so the /// caller's own realm resolution and this decision can never disagree. /// null to proceed; otherwise the veto to return to the SDK. public ServiceResult? Authorize( RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId, AddressSpaceRealm realm) => Evaluate(identity, operation, nodeId, realm, cached: false); /// /// Whether the API key behind is still usable — consulted by the two /// server-wide role gates (WriteOperate, AlarmAck) regardless of /// (Gitea #563). /// /// /// /// With the flag off a session's roles are resolved once, at activation, so before this check a /// key that expired by the clock — or that a deployment disabled or removed — kept /// WriteOperate / AlarmAck on its open session until the client disconnected. With /// the flag on, already makes the identical check first thing, so the role /// gates call this only on the flag-off path and the two postures agree on key usability. /// /// /// It is the same probe against the same /// snapshot and the same clock as : one dictionary lookup, no I/O, so it is /// safe on the SDK's synchronous write and method-call threads. A session carrying no key id — /// Anonymous, or a legacy identity — has nothing that can expire and always passes; its roles stay /// exactly what activation resolved. A key absent from the snapshot denies, as it does with the flag /// on: on a driver-only node the artifact is the only key source there is, so "absent" means /// "removed by a deployment". /// /// /// The role-carrying identity off the SDK context, or null. /// The operation being attempted, for the denial log. /// The target node, for the denial log. /// null to proceed to the role check; otherwise the veto to return to the SDK. public ServiceResult? AuthorizeKeyUsable( RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId) { if (identity?.ApiKeyId is not { Length: > 0 } keyId) return null; var snapshot = _authority.Current; return snapshot.FindUsableKey(keyId, _time.GetUtcNow().UtcDateTime) is null ? DenyRevokedKey(operation, nodeId, identity.Principal, keyId, snapshot.GenerationId, cached: false) : null; } /// /// The decision itself. Ordered so the cached seams reach their answer having allocated nothing: /// every check that must run per call comes first, the memoized verdict is consulted next, and the /// scope build and grant walk happen only on a miss. /// /// /// The ordering is a correctness constraint before it is a performance one — see the key-revocation /// comment inline, and Key_revocation_is_rechecked_on_a_cache_hit / /// NodeManagerStage6EnforcementTests.An_expired_key_is_denied_even_though_its_allow_is_cached, /// which fail if the cache lookup is moved above it. /// /// The role-carrying identity off the SDK context. /// The operation being attempted. /// The target node. /// The namespace the node was resolved in. /// Whether the grant walk may be memoized (the request-rate seams). /// null to proceed; otherwise the veto to return to the SDK. private ServiceResult? Evaluate( RoleCarryingUserIdentity? identity, OpcUaOperation operation, NodeId nodeId, AddressSpaceRealm realm, bool cached) { if (identity is null) return Deny(operation, nodeId, principal: null, "the session carries no role-carrying identity", cached); if (string.IsNullOrEmpty(identity.Principal)) { // An authenticator that predates the principal model, or a code path that built the identity // through the legacy two-argument constructor. Guessing a principal here — from the display // name, say — would silently authorize as whatever the token's username happened to be. return Deny(operation, nodeId, principal: null, "the session resolved no ACL principal", cached); } var snapshot = _authority.Current; if (string.IsNullOrWhiteSpace(snapshot.ClusterId)) { return Deny(operation, nodeId, identity.Principal, "this node has no cluster identity, so no permission trie exists to evaluate against", cached); } // Re-check revocation on every call. The key was verified at session activation, but a deployment // since then may have disabled or expired it — and on a driver-only node the artifact is the ONLY // revocation channel. Without this a revoked key keeps its open session's authority indefinitely, // which on a machine-to-machine data plane means until the client chooses to reconnect. // ⚠️ Deliberately OUTSIDE the decision cache, and it must stay ABOVE the lookup below. Grants and // principals are fixed for a generation, so memoizing the grant walk is sound; a key's expiry // passes with the CLOCK, so a cached allow consulted first would keep an expired key working until // the next deploy — the generation never moves, so nothing would drop the entry. var now = _time.GetUtcNow().UtcDateTime; if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null) return DenyRevokedKey(operation, nodeId, identity.Principal, keyId, snapshot.GenerationId, cached); // The s= identifier, read once: it is both the cache key and the raw material the scope is split // from, and pulling it up here absorbs what used to be TryBuildScope's own guard. A NodeId that // carries no string addresses no ACL scope at all, so it can never be answered from the cache. if (nodeId?.Identifier is not string identifier || identifier.Length == 0) { return Deny(operation, nodeId, identity.Principal, "the node id carries no string identifier, so it addresses no ACL scope", cached); } var sessionKey = identity.ApiKeyId ?? identity.Principal; if (cached && Decisions.TryGet(snapshot.GenerationId, sessionKey, operation, realm, identifier, out var memoized)) { // The whole point of the reorder: a hit returns HERE, having built no scope, no closure and no // UserAuthorizationState. One Browse of a device folder asks this once per child. return memoized ? null : DenyNoGrant(operation, nodeId, identity.Principal, snapshot.GenerationId, cached); } var scope = BuildScope(identifier, realm, snapshot.ClusterId); var allowed = Walk(snapshot, identity, operation, scope, now); // The single shared anchor fronts BOTH realm trees, so its browse must consider transit in // either: a principal whose only Browse grant lives in the UNS tree still enters through this // node. Browse only — transit confers navigation, never read/write/subscribe, matching the // evaluator's own rule. if (!allowed && operation == OpcUaOperation.Browse && IsSharedRootFolder(identifier, realm)) { allowed = Walk( snapshot, identity, operation, new NodeScope { ClusterId = snapshot.ClusterId!, Kind = NodeHierarchyKind.Uns, Segments = [], }, now); } if (cached) Decisions.Set(snapshot.GenerationId, sessionKey, operation, realm, identifier, allowed); return allowed ? null : DenyNoGrant(operation, nodeId, identity.Principal, snapshot.GenerationId, cached); } /// The grant walk itself — the only part of a decision that is safe to memoize. /// The authority snapshot the decision is taken against. /// The calling identity. /// The operation being attempted. /// The resolved node scope. /// The current time, for the authorization state's resolution stamp. /// when a grant confers the operation. private bool Walk( DataPlaneAuthoritySnapshot snapshot, RoleCarryingUserIdentity identity, OpcUaOperation operation, NodeScope scope, DateTime now) { var session = new UserAuthorizationState { SessionId = identity.ApiKeyId ?? identity.Principal, ClusterId = snapshot.ClusterId!, Principals = [identity.Principal], MembershipResolvedUtc = now, AuthGenerationId = snapshot.GenerationId, MembershipVersion = snapshot.GenerationId, // ⚠️ Disables the LDAP-era freshness ceiling explicitly, rather than relying on it never // being reached. Two separate reasons, and the second is the one that bites: // // 1. It is meaningless here. The ceiling exists so a long-lived session re-resolves group // membership a directory changed out of band. Nothing changes out of band under API keys // — principals come from the key and both keys and grants ride the artifact — so a // "stale membership" is not a state this model can be in. // 2. It couples two clocks. MembershipResolvedUtc is stamped from THIS class's TimeProvider, // while IsStale is evaluated against the EVALUATOR's. They are the same instance in // production and diverge the moment anything injects a test clock — at which point the // evaluator silently denies everything with a verdict indistinguishable from "no grant". // That is how this was found: a fake clock two hours ahead made every decision NotGranted. AuthCacheMaxStaleness = TimeSpan.MaxValue, }; return _evaluator.Authorize(session, operation, scope).IsAllowed; } /// /// Projects a node's s= identifier onto the scope the permission trie is keyed by. /// /// /// The s= identifier IS the path in both realms — a RawPath /// (folder…/Driver/Device/group…/Tag) or a UNS path /// (Area/Line/Equipment/EffectiveName) — so the projection is a split, and the depth comes /// out however deep the node actually is. That is why the scope model had to become variable-arity /// in stage 3: a fixed set of level fields could not represent the raw tree at all. /// /// Segments are compared ordinally, matching RawPaths.Comparer and OPC UA NodeId /// identity — Speed and speed are different nodes and must be different scopes. /// /// /// Takes the identifier rather than the , and cannot fail: the "not a /// string" guard this used to carry moved up into Evaluate, where it has to run before /// the cache lookup anyway — the identifier is the cache key. /// /// /// The one identifier that is not a path is the shared root folder /// (), which both realm trees hang under. Its /// identifier is its display identity, so splitting it yields a one-segment scope containing a /// name no grant path can ever hold — which denied the anchor for every principal short of a /// cluster grant, and the node manager refuses a whole Browse whose SOURCE is denied. It maps /// instead onto the zero-segment scope: the realm root, which is exactly the question /// was written to answer ("is this an ancestor of /// anything granted in this realm?") and which no production path had ever asked it. /// /// /// Keying that on the identifier introduces no new ambiguity: a genuine top-level raw folder /// literally named OtOpcUa would already mint the identical NodeId as the anchor in the /// served address space itself (EnsureFolder takes the RawPath as the id), so the /// collision — if an operator ever authors one — predates this mapping and lives in the address /// space rather than in the gate. One narrowing side effect in that degenerate case: a grant /// scoped to the colliding folder used to leak onto the anchor's own Read/Write (the old /// one-segment scope descended into it); the zero-segment scope no longer does, which is the /// fail-closed direction. /// /// /// The node's non-empty s= identifier. /// The namespace the node was resolved in. /// The node's cluster. /// The scope to evaluate against. private static NodeScope BuildScope(string identifier, AddressSpaceRealm realm, string clusterId) => new() { ClusterId = clusterId, Kind = realm == AddressSpaceRealm.Raw ? NodeHierarchyKind.Raw : NodeHierarchyKind.Uns, Segments = IsSharedRootFolder(identifier, realm) ? [] : identifier.Split('/', StringSplitOptions.RemoveEmptyEntries), }; /// /// Whether this node is the single shared root folder both realm trees hang under. /// /// /// Realm-qualified on purpose. The anchor is minted in the RAW namespace; in the UNS /// namespace s=OtOpcUa is an ordinary Area an operator happened to name that way, and giving /// it anchor semantics would collapse a real UNS path onto the realm root — silently widening every /// browse of it. /// /// The node's s= identifier. /// The namespace the node was resolved in. /// when the node is the shared anchor folder. private static bool IsSharedRootFolder(string identifier, AddressSpaceRealm realm) => realm == AddressSpaceRealm.Raw && string.Equals(identifier, V3NodeIds.SharedRootFolderId, StringComparison.Ordinal); /// Denies with a constant reason — nothing to build, so nothing to defer. /// The operation being attempted. /// The target node. /// The calling principal, when one resolved. /// The operator-facing explanation. /// Whether this came from a request-rate seam (which logs at Debug). /// The veto to return to the SDK. private ServiceResult Deny( OpcUaOperation operation, NodeId? nodeId, string? principal, string reason, bool cached = false) { var level = LevelFor(cached); if (_log.IsEnabled(level)) LogDenial(level, operation, nodeId, principal, reason); return DeniedResult; } /// /// Denies because the calling key is disabled or expired, composing the reason only if it will be /// logged. /// /// The operation being attempted. /// The target node. /// The calling principal. /// The key that is no longer usable. /// The authorization generation the check ran under. /// Whether this came from a request-rate seam (which logs at Debug). /// The veto to return to the SDK. private ServiceResult DenyRevokedKey( OpcUaOperation operation, NodeId? nodeId, string? principal, string keyId, long generation, bool cached) { var level = LevelFor(cached); if (_log.IsEnabled(level)) { LogDenial(level, operation, nodeId, principal, $"key {keyId} is no longer enabled or has expired in generation {generation}"); } return DeniedResult; } /// /// Denies because no grant conferred the operation — the hot denial, hit once per child of an /// ungranted folder, so the reason string is built only when Debug is actually on. /// /// The operation being attempted. /// The target node. /// The calling principal. /// The authorization generation the walk ran under. /// Whether this came from a request-rate seam (which logs at Debug). /// The veto to return to the SDK. private ServiceResult DenyNoGrant( OpcUaOperation operation, NodeId? nodeId, string? principal, long generation, bool cached) { var level = LevelFor(cached); if (_log.IsEnabled(level)) { LogDenial(level, operation, nodeId, principal, $"no grant in generation {generation} confers {operation} here"); } return DeniedResult; } /// /// The hot seams log at Debug because a single Browse of an ungranted folder would otherwise emit /// one Information line per child. Their operator-facing signal is the per-request summary /// emits, which says how many references were filtered and for whom. /// /// Whether the denial came from a request-rate seam. /// The level to log the denial at. private static LogLevel LevelFor(bool cached) => cached ? LogLevel.Debug : LogLevel.Information; /// /// Emits the denial detail. Callers guard with first — not for the /// message template, which costs nothing, but because every caller's is a /// composed string and two of them interpolate on the request-rate path. /// /// The level chosen by . /// The operation being attempted. /// The target node. /// The calling principal, when one resolved. /// The operator-facing explanation. private void LogDenial( LogLevel level, OpcUaOperation operation, NodeId? nodeId, string? principal, string reason) => _log.Log( level, "AclGate: denied {Operation} on {NodeId} for principal {Principal} — {Reason}", operation, nodeId, principal ?? "(none)", reason); }