feat(security): enforce node ACLs on write, alarms and HistoryRead (#520 stage 5)

`Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide
data-plane authorization at the three seams that have one, and gives HistoryRead
a check it never had. Read, Browse and Subscribe stay ungated — stage 6.

The ACL decision REPLACES the server-wide role check rather than chaining with
it. Chained, a grant would have to confer the role as well as pass the scope
check, and only Cluster-scoped grants confer a role — so every narrow grant
would have stayed inert and the flag would have looked inert too. The
load-bearing test is an ALLOW: a session carrying no roles at all writing
successfully on the strength of its grant. A deny-only test passes against a
build where the ACL is never consulted, because the role gate denies too.

Found en route: the principal never reached the session. The authenticator
passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity
derives DisplayName from the presented token — for a key-bearing client that is
the static username `apikey`, identical fleet-wide. So the trie's matching key
was dropped at the identity boundary, and the audit ClientUserId on a rejected
write recorded `apikey` for every consuming system. Principal and ApiKeyId are
now carried explicitly.

Beyond the stage as sketched:

- Revocation reaches an OPEN session. Every decision re-checks the
  authenticating key against the current snapshot; without it a key revoked by a
  deployment kept its authority until the client chose to reconnect, which for a
  machine-to-machine consumer can be indefinitely.
- UserAuthorizationState is built per CALL, not per session. The per-session
  shape exists so an LDAP session can re-resolve membership that changed out of
  band; nothing can change out of band under API keys, so the generation stamp is
  the whole invalidation signal and a per-call record picks it up for free.
  IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial.
- The trie is built inside DataPlaneAuthorityState.Apply, under the same
  generation as the snapshot, rather than by a second wiring at the publish
  actor. An uninstalled trie denies everything — indistinguishable from working
  enforcement. Deleting the install turns 13 of the 22 new tests red.
- The deploy log reports what the BUILD dropped instead of re-deriving it. The
  old count agreed with the builder on one of its four drop reasons; a rule with
  a blank ScopeId, a retired scope kind, or a path not starting at a realm
  vanished with the log reading zero.

Both IApiKeyStore implementations widened to the full grant set. The narrowing
was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and
became a trap once scope was enforced. Pinned by asserting the roles are
unchanged, with the two grants carrying different flags so it cannot pass by
accident.

Falsified by mutation: enforcement never engaging, the write seam ignoring the
ACL, the realm dropped from the scope, the revocation check removed, and the
trie never installed each turn the intended tests red and no others.

Full solution suite green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 05:00:28 -04:00
parent 9499099028
commit f692f6afe4
25 changed files with 1845 additions and 151 deletions
@@ -0,0 +1,197 @@
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. <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.
/// </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.
/// </para>
/// </remarks>
public sealed class DataPlaneAuthorizer
{
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>
/// 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)
{
if (identity is null)
return Deny(operation, nodeId, principal: null, "the session carries no role-carrying identity");
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");
}
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");
}
// 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.
var now = _time.GetUtcNow().UtcDateTime;
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}");
}
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");
}
var session = new UserAuthorizationState
{
SessionId = identity.ApiKeyId ?? identity.Principal,
ClusterId = snapshot.ClusterId,
Principals = [identity.Principal],
MembershipResolvedUtc = now,
AuthGenerationId = snapshot.GenerationId,
MembershipVersion = snapshot.GenerationId,
};
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");
}
/// <summary>
/// Projects an SDK <see cref="NodeId"/> 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>
/// </remarks>
/// <param name="nodeId">The target node.</param>
/// <param name="realm">The namespace the node was resolved in.</param>
/// <param name="clusterId">The node's cluster.</param>
/// <param name="scope">The resolved scope.</param>
/// <returns><see langword="true"/> when a scope could be built.</returns>
private static bool TryBuildScope(
NodeId nodeId, AddressSpaceRealm realm, string clusterId, out NodeScope scope)
{
scope = null!;
if (nodeId?.Identifier is not string identifier || identifier.Length == 0) return false;
scope = new NodeScope
{
ClusterId = clusterId,
Kind = realm == AddressSpaceRealm.Raw ? NodeHierarchyKind.Raw : NodeHierarchyKind.Uns,
Segments = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries),
};
return true;
}
private ServiceResult Deny(OpcUaOperation operation, NodeId? nodeId, string? principal, string reason)
{
// 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(
"AclGate: denied {Operation} on {NodeId} for principal {Principal} — {Reason}",
operation, nodeId, principal ?? "(none)", reason);
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
@@ -51,18 +51,50 @@ public interface IOpcUaUserAuthenticator
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
/// <remarks>
/// <para>
/// ⚠️ <b><see cref="DisplayName"/> is not the principal, and assuming it was is a live trap</b>
/// (#520 stage 5). The authenticator passes the principal as the display name, but
/// <c>RoleCarryingUserIdentity</c> derives <c>UserIdentity.DisplayName</c> from the presented
/// <b>token</b> — which for a key-bearing client is the static username <c>apikey</c>, the same
/// for every key in the fleet. So the principal was being dropped on the floor at the identity
/// boundary, and the audit <c>ClientUserId</c> on a rejected write recorded <c>apikey</c> for
/// everyone. <see cref="Principal"/> carries it explicitly; nothing should infer it from a name.
/// </para>
/// </remarks>
public sealed record OpcUaUserAuthResult(
bool Success,
string? DisplayName,
IReadOnlyList<string> Roles,
string? Error)
{
/// <summary>
/// The ACL principal the session authenticates as — the name <c>NodeAcl</c> rows are keyed on.
/// Empty on a denial, and on a legacy authenticator that predates the principal model, which the
/// ACL gate treats as unauthorized rather than guessing.
/// </summary>
public string Principal { get; init; } = string.Empty;
/// <summary>
/// Public id of the API key that authenticated, or null for anonymous. Carried so an open session
/// can be re-checked against a later deployment that revoked the key.
/// </summary>
public string? ApiKeyId { get; init; }
/// <summary>Creates a successful authentication result with the specified display name and roles.</summary>
/// <param name="displayName">The display name for the authenticated user.</param>
/// <param name="displayName">The display name for the authenticated user — also taken as the principal.</param>
/// <param name="roles">The roles to assign to the authenticated user.</param>
/// <returns>A successful OpcUaUserAuthResult.</returns>
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
new(true, displayName, roles, null);
new(true, displayName, roles, null) { Principal = displayName ?? string.Empty };
/// <summary>Creates a successful result for a key-bearing client, carrying the key id for revocation re-checks.</summary>
/// <param name="principal">The ACL principal the key maps to.</param>
/// <param name="roles">The interim server-wide roles.</param>
/// <param name="apiKeyId">The public key id that authenticated.</param>
/// <returns>A successful OpcUaUserAuthResult.</returns>
public static OpcUaUserAuthResult AllowKey(string principal, IReadOnlyList<string> roles, string apiKeyId) =>
new(true, principal, roles, null) { Principal = principal, ApiKeyId = apiKeyId };
/// <summary>Creates a failed authentication result with the specified error message.</summary>
/// <param name="error">The error message describing why authentication failed.</param>
@@ -21,6 +21,20 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
/// <param name="token">The user identity token the SDK handed to the impersonation handler.</param>
/// <param name="roles">The roles resolved by the authenticator (e.g. <c>ReadOnly</c>, <c>AlarmAck</c>).</param>
public RoleCarryingUserIdentity(UserIdentityToken token, IReadOnlyList<string> roles)
: this(token, roles, principal: string.Empty, apiKeyId: null)
{
}
/// <summary>
/// Initializes a new instance carrying the ACL principal and the authenticating key id alongside
/// the roles (#520 stage 5).
/// </summary>
/// <param name="token">The user identity token the SDK handed to the impersonation handler.</param>
/// <param name="roles">The roles resolved by the authenticator.</param>
/// <param name="principal">The ACL principal this session authenticates as.</param>
/// <param name="apiKeyId">The public id of the key that authenticated, or null for anonymous.</param>
public RoleCarryingUserIdentity(
UserIdentityToken token, IReadOnlyList<string> roles, string principal, string? apiKeyId)
: base(token)
{
ArgumentNullException.ThrowIfNull(roles);
@@ -28,8 +42,30 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
// alter this session's granted roles, and so the gate's per-ack `.Contains(...)` runs over a
// known-small frozen array.
Roles = [.. roles];
Principal = principal ?? string.Empty;
ApiKeyId = apiKeyId;
}
/// <summary>The roles the authenticator granted this session, used by downstream permission gates.</summary>
public IReadOnlyList<string> Roles { get; }
/// <summary>
/// The ACL principal this session authenticates as — the name <c>NodeAcl</c> rows key on, and what
/// the permission trie matches. Empty when the authenticator predates the principal model, which
/// the ACL gate treats as unauthorized.
/// </summary>
/// <remarks>
/// ⚠️ <b>This is NOT <see cref="UserIdentity.DisplayName"/>.</b> The base class derives that from
/// the presented token, so for a key-bearing client it is the static username <c>apikey</c> —
/// identical for every key in the fleet and useless as an identity. See
/// <see cref="OpcUaUserAuthResult.Principal"/>.
/// </remarks>
public string Principal { get; }
/// <summary>
/// Public id of the API key that authenticated this session, or null for anonymous. Lets an open
/// session be re-checked against a later deployment that revoked the key, instead of the key
/// remaining usable until the client happens to disconnect.
/// </summary>
public string? ApiKeyId { get; }
}