2b945672e9
#562: direct event (alarm) monitored items are now tracked in a second map (_eventItems, fed by the SubscribeToEvents override) and re-authorized by RevalidateMonitoredItems on both triggers (DataPlaneAuthorityState.Changed and AclRevalidationTimer). A denied item is detached via the SDK's own protected SubscribeToEvents(unsubscribe: true) - never MonitoringMode.Disabled - and re-attached on restore; a client delete of a stood-down item is absorbed. Over-the-wire tests: revoke -> stop (positive control on a still-granted notifier) -> restore -> resume; clock-driven expiry via the real timer with a control session; delete-while-stood-down is not resurrected; unexpired negative control. Server-object (all-events) subscriptions are documented as outside the ACL (observed over the wire). #563: DataPlaneAuthorizer.AuthorizeKeyUsable runs the FindUsableKey probe on the WriteOperate and AlarmAck role gates regardless of Security:Acl:Enabled, so an expired or deployment-disabled key loses its activation-time roles on its open session. Anonymous (no key id) is unaffected; reads/subscriptions stay ungated with the flag off. No new flag. Docs: docs/security.md and the CLAUDE.md API-key/ACL section. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
542 lines
31 KiB
C#
542 lines
31 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Turns an inbound OPC UA request into a node-scoped ACL decision (#520, stage 5). The one place
|
|
/// the address space's <see cref="NodeId"/> meets <see cref="IPermissionEvaluator"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Dormant unless <see cref="Enabled"/>.</b> With <c>Security:Acl:Enabled</c> false —
|
|
/// the default — <see cref="Authorize"/> is never consulted and the node manager keeps its
|
|
/// server-wide role gates. See <see cref="AclOptions"/> for why the two are alternatives rather
|
|
/// than a chain.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Every failure path denies.</b> No identity, no principal, no cluster identity, a NodeId
|
|
/// whose identifier is not a string, a trie that was never installed — each returns
|
|
/// <c>BadUserAccessDenied</c>. 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 <c>OpcUaPublish</c> is where an operator confirms a trie exists at
|
|
/// all.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Authorization state is built per call, not per session.</b> <see cref="UserAuthorizationState"/>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Stage 6's request-rate seams — Read, Browse, subscription creation — go through
|
|
/// <see cref="AuthorizeCached"/> instead, which memoizes the grant walk in
|
|
/// <see cref="Decisions"/>. That is a cache of the walk, <b>not</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="UserAuthorizationState.IsStale"/> and
|
|
/// <see cref="UserAuthorizationState.NeedsRefresh"/> 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 <b>explicitly</b> (<c>AuthCacheMaxStaleness = TimeSpan.MaxValue</c>) 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".
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DataPlaneAuthorizer
|
|
{
|
|
/// <summary>
|
|
/// The single veto every denial returns. Logged, never explained to the client: the client gets an
|
|
/// undifferentiated <c>BadUserAccessDenied</c> 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 <c>DenyMessage</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Shared rather than per-call, because <see cref="ServiceResult"/> is immutable to us.</b>
|
|
/// Verified by reflection against the pinned Opc.Ua.Core 1.5.378.106: every one of its properties
|
|
/// (<c>StatusCode</c>, <c>SymbolicId</c>, <c>NamespaceUri</c>, <c>LocalizedText</c>,
|
|
/// <c>AdditionalInfo</c>, <c>InnerResult</c>) has a <b>private</b> setter, and its only non-getter
|
|
/// method — <c>Append(StringBuilder)</c> — formats rather than mutates, so no code outside the type
|
|
/// can change an instance. The SDK itself publishes <c>ServiceResult.Good</c> and
|
|
/// <c>ServiceResult.Bad</c> as shared <c>static readonly</c> 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.
|
|
/// </remarks>
|
|
private static readonly ServiceResult DeniedResult = new(StatusCodes.BadUserAccessDenied);
|
|
|
|
private readonly IDataPlaneAuthoritySource _authority;
|
|
private readonly IPermissionEvaluator _evaluator;
|
|
private readonly ILogger _log;
|
|
private readonly TimeProvider _time;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="DataPlaneAuthorizer"/> class.</summary>
|
|
/// <param name="authority">The node's current data-plane authorization inputs.</param>
|
|
/// <param name="evaluator">The permission evaluator reading the tries built alongside those inputs.</param>
|
|
/// <param name="enabled">Whether ACL evaluation decides authorization (<c>Security:Acl:Enabled</c>).</param>
|
|
/// <param name="log">The logger.</param>
|
|
/// <param name="time">Time provider, injected for testability.</param>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when ACL grants — rather than the server-wide role set — decide write, alarm-command and
|
|
/// HistoryRead authorization.
|
|
/// </summary>
|
|
public bool Enabled { get; }
|
|
|
|
/// <summary>The clock this gate evaluates key expiry against.</summary>
|
|
/// <remarks>
|
|
/// Exposed so the composition root's wiring can be asserted by reference. The remarks on
|
|
/// <c>UserAuthorizationState</c> 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.
|
|
/// </remarks>
|
|
public TimeProvider Clock => _time;
|
|
|
|
/// <summary>
|
|
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
|
|
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.
|
|
/// </summary>
|
|
public SessionAclDecisionCache Decisions { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Authorizes <paramref name="operation"/> on <paramref name="nodeId"/> through the decision cache —
|
|
/// the entry point for Read, Browse and subscription creation, which ask the same question many
|
|
/// times per request.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Identical in outcome to <see cref="Authorize"/>; 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.
|
|
/// </remarks>
|
|
/// <param name="identity">The role-carrying identity off the SDK context, or null when the session carries none.</param>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="nodeId">The target node.</param>
|
|
/// <param name="realm">The namespace the node was resolved in.</param>
|
|
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
|
public ServiceResult? AuthorizeCached(
|
|
RoleCarryingUserIdentity? identity,
|
|
OpcUaOperation operation,
|
|
NodeId nodeId,
|
|
AddressSpaceRealm realm)
|
|
=> Evaluate(identity, operation, nodeId, realm, cached: true);
|
|
|
|
/// <summary>
|
|
/// Emits the one operator-facing line per gated request, summarising what a hot seam withheld.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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 <c>CustomNodeManager2</c> and carries no
|
|
/// <c>ILogger</c> — everything it logs itself goes through the SDK's obsolete static trace.
|
|
/// </remarks>
|
|
/// <param name="operation">The service being filtered, e.g. <c>Browse</c>.</param>
|
|
/// <param name="principal">The calling principal, or null when the session resolved none.</param>
|
|
/// <param name="withheld">How many nodes or references were withheld.</param>
|
|
/// <param name="total">How many were considered.</param>
|
|
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)");
|
|
}
|
|
|
|
/// <summary>Whether <paramref name="identity"/> may perform <paramref name="operation"/>, as a bool.</summary>
|
|
/// <param name="identity">The role-carrying identity off the SDK context.</param>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="nodeId">The target node.</param>
|
|
/// <param name="realm">The namespace the node was resolved in.</param>
|
|
/// <returns><see langword="true"/> when the operation is permitted.</returns>
|
|
public bool IsAllowedCached(
|
|
RoleCarryingUserIdentity? identity,
|
|
OpcUaOperation operation,
|
|
NodeId nodeId,
|
|
AddressSpaceRealm realm)
|
|
=> AuthorizeCached(identity, operation, nodeId, realm) is null;
|
|
|
|
/// <summary>
|
|
/// Authorizes <paramref name="operation"/> on <paramref name="nodeId"/> for the session behind
|
|
/// <paramref name="identity"/>.
|
|
/// </summary>
|
|
/// <param name="identity">The role-carrying identity off the SDK context, or null when the session
|
|
/// carries none.</param>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="nodeId">The target node.</param>
|
|
/// <param name="realm">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.</param>
|
|
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
|
public ServiceResult? Authorize(
|
|
RoleCarryingUserIdentity? identity,
|
|
OpcUaOperation operation,
|
|
NodeId nodeId,
|
|
AddressSpaceRealm realm)
|
|
=> Evaluate(identity, operation, nodeId, realm, cached: false);
|
|
|
|
/// <summary>
|
|
/// Whether the API key behind <paramref name="identity"/> is still usable — consulted by the two
|
|
/// server-wide role gates (<c>WriteOperate</c>, <c>AlarmAck</c>) <b>regardless of
|
|
/// <see cref="Enabled"/></b> (Gitea #563).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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
|
|
/// <c>WriteOperate</c> / <c>AlarmAck</c> on its open session until the client disconnected. With
|
|
/// the flag on, <see cref="Authorize"/> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// It is the same <see cref="DataPlaneAuthoritySnapshot.FindUsableKey"/> probe against the same
|
|
/// snapshot and the same clock as <see cref="Authorize"/>: one dictionary lookup, no I/O, so it is
|
|
/// safe on the SDK's synchronous write and method-call threads. A session carrying <b>no</b> 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".
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
|
|
/// <param name="operation">The operation being attempted, for the denial log.</param>
|
|
/// <param name="nodeId">The target node, for the denial log.</param>
|
|
/// <returns><c>null</c> to proceed to the role check; otherwise the veto to return to the SDK.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The ordering is a correctness constraint before it is a performance one — see the key-revocation
|
|
/// comment inline, and <c>Key_revocation_is_rechecked_on_a_cache_hit</c> /
|
|
/// <c>NodeManagerStage6EnforcementTests.An_expired_key_is_denied_even_though_its_allow_is_cached</c>,
|
|
/// which fail if the cache lookup is moved above it.
|
|
/// </remarks>
|
|
/// <param name="identity">The role-carrying identity off the SDK context.</param>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="nodeId">The target node.</param>
|
|
/// <param name="realm">The namespace the node was resolved in.</param>
|
|
/// <param name="cached">Whether the grant walk may be memoized (the request-rate seams).</param>
|
|
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>The grant walk itself — the only part of a decision that is safe to memoize.</summary>
|
|
/// <param name="snapshot">The authority snapshot the decision is taken against.</param>
|
|
/// <param name="identity">The calling identity.</param>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="scope">The resolved node scope.</param>
|
|
/// <param name="now">The current time, for the authorization state's resolution stamp.</param>
|
|
/// <returns><see langword="true"/> when a grant confers the operation.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Projects a node's <c>s=</c> identifier onto the scope the permission trie is keyed by.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The <c>s=</c> identifier IS the path in both realms — a RawPath
|
|
/// (<c>folder…/Driver/Device/group…/Tag</c>) or a UNS path
|
|
/// (<c>Area/Line/Equipment/EffectiveName</c>) — 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.
|
|
/// <para>
|
|
/// Segments are compared ordinally, matching <c>RawPaths.Comparer</c> and OPC UA NodeId
|
|
/// identity — <c>Speed</c> and <c>speed</c> are different nodes and must be different scopes.
|
|
/// </para>
|
|
/// <para>
|
|
/// Takes the identifier rather than the <see cref="NodeId"/>, and cannot fail: the "not a
|
|
/// string" guard this used to carry moved up into <c>Evaluate</c>, where it has to run before
|
|
/// the cache lookup anyway — the identifier is the cache key.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The one identifier that is not a path is the shared root folder</b>
|
|
/// (<see cref="V3NodeIds.SharedRootFolderId"/>), 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
|
|
/// <see cref="PermissionTrie.HasBrowseTransit"/> was written to answer ("is this an ancestor of
|
|
/// anything granted in this realm?") and which no production path had ever asked it.
|
|
/// </para>
|
|
/// <para>
|
|
/// Keying that on the identifier introduces no new ambiguity: a genuine top-level raw folder
|
|
/// literally named <c>OtOpcUa</c> would already mint the identical NodeId as the anchor in the
|
|
/// served address space itself (<c>EnsureFolder</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="identifier">The node's non-empty <c>s=</c> identifier.</param>
|
|
/// <param name="realm">The namespace the node was resolved in.</param>
|
|
/// <param name="clusterId">The node's cluster.</param>
|
|
/// <returns>The scope to evaluate against.</returns>
|
|
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),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Whether this node is the single shared root folder both realm trees hang under.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Realm-qualified on purpose.</b> The anchor is minted in the RAW namespace; in the UNS
|
|
/// namespace <c>s=OtOpcUa</c> 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.
|
|
/// </remarks>
|
|
/// <param name="identifier">The node's <c>s=</c> identifier.</param>
|
|
/// <param name="realm">The namespace the node was resolved in.</param>
|
|
/// <returns><see langword="true"/> when the node is the shared anchor folder.</returns>
|
|
private static bool IsSharedRootFolder(string identifier, AddressSpaceRealm realm) =>
|
|
realm == AddressSpaceRealm.Raw
|
|
&& string.Equals(identifier, V3NodeIds.SharedRootFolderId, StringComparison.Ordinal);
|
|
|
|
/// <summary>Denies with a constant reason — nothing to build, so nothing to defer.</summary>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="nodeId">The target node.</param>
|
|
/// <param name="principal">The calling principal, when one resolved.</param>
|
|
/// <param name="reason">The operator-facing explanation.</param>
|
|
/// <param name="cached">Whether this came from a request-rate seam (which logs at Debug).</param>
|
|
/// <returns>The veto to return to the SDK.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Denies because the calling key is disabled or expired, composing the reason only if it will be
|
|
/// logged.
|
|
/// </summary>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="nodeId">The target node.</param>
|
|
/// <param name="principal">The calling principal.</param>
|
|
/// <param name="keyId">The key that is no longer usable.</param>
|
|
/// <param name="generation">The authorization generation the check ran under.</param>
|
|
/// <param name="cached">Whether this came from a request-rate seam (which logs at Debug).</param>
|
|
/// <returns>The veto to return to the SDK.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="nodeId">The target node.</param>
|
|
/// <param name="principal">The calling principal.</param>
|
|
/// <param name="generation">The authorization generation the walk ran under.</param>
|
|
/// <param name="cached">Whether this came from a request-rate seam (which logs at Debug).</param>
|
|
/// <returns>The veto to return to the SDK.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="LogFiltered"/> emits, which says how many references were filtered and for whom.
|
|
/// </summary>
|
|
/// <param name="cached">Whether the denial came from a request-rate seam.</param>
|
|
/// <returns>The level to log the denial at.</returns>
|
|
private static LogLevel LevelFor(bool cached) => cached ? LogLevel.Debug : LogLevel.Information;
|
|
|
|
/// <summary>
|
|
/// Emits the denial detail. Callers guard with <see cref="ILogger.IsEnabled"/> first — not for the
|
|
/// message template, which costs nothing, but because every caller's <paramref name="reason"/> is a
|
|
/// composed string and two of them interpolate on the request-rate path.
|
|
/// </summary>
|
|
/// <param name="level">The level chosen by <see cref="LevelFor"/>.</param>
|
|
/// <param name="operation">The operation being attempted.</param>
|
|
/// <param name="nodeId">The target node.</param>
|
|
/// <param name="principal">The calling principal, when one resolved.</param>
|
|
/// <param name="reason">The operator-facing explanation.</param>
|
|
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);
|
|
}
|