feat(security): enforce node ACLs on read, browse and subscribe (#520 stage 6)
Completes data-plane enforcement behind Security:Acl:Enabled. Read (every attribute, not just Value), Browse, TranslateBrowsePath, CreateMonitoredItems, SubscribeToEvents and SetMonitoringMode now consult scoped NodeAcl grants. With the flag off nothing changes. Four things went beyond the stage as written, each because leaving it out would have made the feature look like it worked: - Browse needed ancestor visibility. The trie inherits downward, and a client reaches a deep node by browsing the nodes above it, so without this every grant narrower than the cluster was authored, shipped and unreachable. It is a separate non-inheriting marker, NOT a synthesized Browse grant on each ancestor: the additive downward walk would carry such a grant from Plant onto Plant/Siemens/dev9 and every other sibling subtree. Pinned by a test that goes through the evaluator — probing the trie helper directly passes against the broken design, because a sibling is absent from the trie either way. - Revocation had to reach LIVE subscriptions. Every other operation re-asks per request; a subscription is authorized once and then streams, and streaming is how machine consumers actually read this server — so stopping only the re-asking operations would have made revocation close to cosmetic. IDataPlaneAuthoritySource gained a Changed event. A denied item is queued BadUserAccessDenied and then DETACHED from its MonitoredNode2 fan-out, not set MonitoringMode.Disabled. Disabling discards the item's queued notifications, so the client is silenced without ever being told why. The over-the-wire test caught this; the unit-level path reports the item as stood down either way. Detaching is also reversible, so a restored grant resumes the feed with no client action. - The decision cache holds the grant WALK, not the decision. Grants and the trie are fixed for a generation; a key's ExpiresUtc passes with the clock, so caching the whole decision would let an expired key keep working until the next deploy — the stage-5 open-session hole in a subtler form. - TranslateBrowsePath is gated, without which browse filtering is decorative: a client refused a browse asks TranslateBrowsePathsToNodeIds for the same path. Found en route: UserAuthorizationState's staleness ceiling couples two clocks. MembershipResolvedUtc is stamped from DataPlaneAuthorizer's TimeProvider while IsStale is evaluated against TriePermissionEvaluator's; they are the same instance in production and diverge the moment anything injects a test clock, at which point the evaluator denies EVERYTHING with a verdict indistinguishable from "no grant matched". Stage 5's claim that these predicates cannot fire was accidentally true; they are now disabled explicitly. Also fixed: ContinuationPoint.NodeToBrowse is the manager HANDLE, not a NodeState. The declared type is object, so the wrong cast compiled cleanly and silently yielded null — skipping the browse source gate entirely. Falsified: disabling the transit marking reddens exactly the three tests that assert transit works; implementing transit as an ancestor grant reddens the sibling-leak test; skipping the fan-out detach reddens the revocation test. Full solution suite green. The stage 8 live gate has NOT run yet. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -32,15 +32,24 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
/// 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. <b>The publish path is hot, and it is stage 6's
|
||||
/// problem</b>: that seam needs a per-(session, node) decision cache, not this.
|
||||
/// command or HistoryRead, none of which are hot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A consequence worth stating: because the state is rebuilt each call against the current
|
||||
/// generation, <see cref="UserAuthorizationState.IsStale"/> and
|
||||
/// <see cref="UserAuthorizationState.NeedsRefresh"/> can never fire on this path. They are
|
||||
/// vestigial here rather than load-bearing, and a reader should not conclude from their presence
|
||||
/// that a freshness timer is doing anything.
|
||||
/// 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
|
||||
@@ -80,6 +89,72 @@ public sealed class DataPlaneAuthorizer
|
||||
/// </summary>
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <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"/>.
|
||||
@@ -96,16 +171,24 @@ public sealed class DataPlaneAuthorizer
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm)
|
||||
=> Evaluate(identity, operation, nodeId, realm, cached: false);
|
||||
|
||||
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");
|
||||
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");
|
||||
return Deny(operation, nodeId, principal: null, "the session resolved no ACL principal", cached);
|
||||
}
|
||||
|
||||
var snapshot = _authority.Current;
|
||||
@@ -113,41 +196,81 @@ public sealed class DataPlaneAuthorizer
|
||||
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");
|
||||
"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 there. 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 would keep an expired key working until the next deploy.
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var sessionKey = identity.ApiKeyId ?? identity.Principal;
|
||||
if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null)
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"key {keyId} is no longer enabled or has expired in generation {snapshot.GenerationId}");
|
||||
$"key {keyId} is no longer enabled or has expired in generation {snapshot.GenerationId}", cached);
|
||||
}
|
||||
|
||||
if (!TryBuildScope(nodeId, realm, snapshot.ClusterId, out var scope))
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"the node id carries no string identifier, so it addresses no ACL scope");
|
||||
"the node id carries no string identifier, so it addresses no ACL scope", cached);
|
||||
}
|
||||
|
||||
var allowed = cached
|
||||
? Decisions.GetOrAdd(
|
||||
snapshot.GenerationId, sessionKey, operation, realm, (string)nodeId.Identifier,
|
||||
() => Walk(snapshot, identity, operation, scope, now))
|
||||
: Walk(snapshot, identity, operation, scope, now);
|
||||
|
||||
if (allowed) return null;
|
||||
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"no grant in generation {snapshot.GenerationId} confers {operation} here", 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,
|
||||
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,
|
||||
};
|
||||
|
||||
var decision = _evaluator.Authorize(session, operation, scope);
|
||||
if (decision.IsAllowed) return null;
|
||||
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"no grant in generation {snapshot.GenerationId} confers {operation} here");
|
||||
return _evaluator.Authorize(session, operation, scope).IsAllowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -184,12 +307,18 @@ public sealed class DataPlaneAuthorizer
|
||||
return true;
|
||||
}
|
||||
|
||||
private ServiceResult Deny(OpcUaOperation operation, NodeId? nodeId, string? principal, string reason)
|
||||
private ServiceResult Deny(
|
||||
OpcUaOperation operation, NodeId? nodeId, string? principal, string reason, bool cached = false)
|
||||
{
|
||||
// Logged, never returned. The client gets an undifferentiated BadUserAccessDenied so the server
|
||||
// cannot be used to probe which nodes exist or which grants a principal holds; the operator
|
||||
// debugging a denial reads the reason here. Same posture as the authenticator's DenyMessage.
|
||||
_log.LogInformation(
|
||||
//
|
||||
// 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 the node
|
||||
// manager logs, which says how many references were filtered and for whom.
|
||||
_log.Log(
|
||||
cached ? LogLevel.Debug : LogLevel.Information,
|
||||
"AclGate: denied {Operation} on {NodeId} for principal {Principal} — {Reason}",
|
||||
operation, nodeId, principal ?? "(none)", reason);
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
Reference in New Issue
Block a user