Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs
T
Joseph Doherty 2b945672e9 fix(acl): revalidate event/alarm subscriptions on both triggers; key-usability check on role gates (#562, #563)
#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
2026-10-07 23:10:35 -04:00

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);
}