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
@@ -29,21 +29,28 @@ else if (!IsNew && _existing is null)
else
{
<div class="alert alert-warning" role="alert">
<strong>Only <span class="mono">Cluster</span> scope is enforced today.</strong>
Saved with scope kind <span class="mono">Cluster</span>, this grant confers a server-wide role
on its principal: <span class="mono">WriteOperate</span> → writing any tag value;
<strong>What this grant does depends on <span class="mono">Security:Acl:Enabled</span> on the
node serving the data.</strong> That is per-node configuration, so this page cannot tell you
which mode a given node is in — check the node's own settings.
<br />
<strong>With it off (the default):</strong> saved with scope kind
<span class="mono">Cluster</span> this grant confers a <em>server-wide</em> role on its
principal — <span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
(acknowledge / confirm / shelve). Saved with any narrower scope
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) it is stored and shipped in every
deployment artifact but <strong>confers nothing</strong>. That under-granting is deliberate:
honouring its flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
equipment" into server-wide write authority.
<br />
Saved with any narrower scope (<span class="mono">Namespace</span>,
<span class="mono">UnsArea</span>, <span class="mono">UnsLine</span>,
<span class="mono">Equipment</span>, <span class="mono">Tag</span>,
<span class="mono">Raw*</span>) it is stored and shipped in every deployment artifact but
<strong>confers nothing</strong> until per-node enforcement lands. That under-granting is
deliberate: honouring its flags while its <em>scope</em> cannot be honoured would turn
"WriteOperate on one equipment" into server-wide write authority.
<strong>With it on:</strong> the scope is honoured, and the scoped decision <em>replaces</em> the
server-wide role rather than adding to it. Writes, alarm commands and HistoryRead are gated; the
alarm methods separate into <span class="mono">AlarmAcknowledge</span> /
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>.
<br />
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
<strong>Read, Browse and Subscribe are not gated in either mode</strong>, for any admitted
session, including anonymous.
<br />
<strong>Principal</strong> is an API-key principal — or the reserved
@@ -20,21 +20,31 @@
else
{
<div class="alert alert-warning" role="alert">
<strong>Only <span class="mono">Cluster</span>-scoped grants are enforced today.</strong>
A Cluster-scoped row confers a server-wide role on its principal:
<strong>What these grants do depends on
<span class="mono">Security:Acl:Enabled</span> on the node serving the data.</strong>
That is per-node configuration, so this page cannot tell you which mode a given node is in —
check the node's own settings.
<br />
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
do anything, and they confer a <em>server-wide</em> role on their principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). No other permission bit is evaluated yet.
<br />
<strong>Narrower grants confer nothing.</strong>
<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
(acknowledge / confirm / shelve). Narrower grants
(<span class="mono">Namespace</span>, <span class="mono">UnsArea</span>,
<span class="mono">UnsLine</span>, <span class="mono">Equipment</span>,
<span class="mono">Tag</span> and the <span class="mono">Raw*</span> kinds are saved and shipped
in every deployment artifact, but stay inert until per-node enforcement lands. That
under-granting is deliberate: honouring such a grant's flags while its <em>scope</em> cannot be
honoured would turn "WriteOperate on one equipment" into server-wide write authority.
<span class="mono">Tag</span>, <span class="mono">Raw*</span>) are saved and shipped in every
deployment artifact but confer <strong>nothing</strong>. That under-granting is deliberate:
honouring such a grant's flags while its <em>scope</em> cannot be honoured would turn
"WriteOperate on one equipment" into server-wide write authority.
<br />
Read, Browse, Subscribe and HistoryRead are <strong>not</strong> gated for any admitted
<strong>With it on:</strong> every scope kind is honoured, and the scoped decision
<em>replaces</em> the server-wide role rather than adding to it — so a narrow grant works, and a
Cluster-scoped one no longer confers authority beyond that cluster. Writes, alarm commands and
HistoryRead are gated; the alarm methods separate into
<span class="mono">AlarmAcknowledge</span> / <span class="mono">AlarmConfirm</span> /
<span class="mono">AlarmShelve</span>.
<br />
<strong>Read, Browse and Subscribe are not gated in either mode</strong>, for any admitted
session, including anonymous.
<br />
Identity is an <strong>API key</strong>, not LDAP: a client presents an OPC UA UserName token
@@ -140,7 +140,10 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
"ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]",
lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles));
return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles);
// AllowKey, not Allow: the key id rides along so an open session can be re-checked against a later
// deployment that revoked this key. Without it a revoked key keeps working until the client
// happens to disconnect, which on a machine-to-machine data plane can be indefinitely.
return OpcUaUserAuthResult.AllowKey(lookup.Key.Principal, roles, apiKeyId);
}
/// <inheritdoc />
@@ -31,6 +31,22 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// safe direction, and it is also honest — a node that has not been deployed to has no configured
/// keys.
/// </para>
/// <para>
/// <b>Returns a principal's FULL grant set</b> (#520 stage 5). It used to narrow to the
/// <c>Cluster</c>-scoped rows, because those were the only ones that could confer an interim role.
/// The narrowing was always redundant — <see cref="ApiKeyInterimRoleMapper"/> applies exactly the
/// same filter itself — and it became a trap once scope was enforced: a caller reasonably reading
/// <c>ApiKeyLookup.Grants</c> as "this principal's grants" would have got a silently truncated set.
/// Removing it changes no role.
/// </para>
/// <para>
/// <b>Cluster confinement is not this store's job.</b>
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> already kept only the node's own cluster's ACL
/// rows (<c>ClusterFilterMode.ScopeTo</c>, matched <see cref="StringComparison.OrdinalIgnoreCase"/>),
/// so the snapshot cannot contain another cluster's grant. <see cref="ConfigDbApiKeyStore"/> reads
/// the fleet-wide ConfigDb and therefore has to apply that confinement itself; the two stores agree
/// on the outcome, not on the mechanism.
/// </para>
/// </remarks>
public sealed class ArtifactApiKeyStore : IApiKeyStore
{
@@ -53,38 +69,10 @@ public sealed class ArtifactApiKeyStore : IApiKeyStore
var key = snapshot.FindUsableKey(apiKeyId, utcNow);
return Task.FromResult(key is null
? null
: new ApiKeyLookup(key, ClusterGrants(snapshot, key.Principal)));
: new ApiKeyLookup(key, snapshot.GrantsFor(key.Principal)));
}
/// <inheritdoc />
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult(ClusterGrants(_authority.Current, principal));
/// <summary>
/// Narrows a principal's grants to the Cluster-scoped ones. Only those confer an interim role
/// (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens both stores to the full grant set
/// once scope is actually enforced.
/// </summary>
/// <remarks>
/// <b>Scope kind is the ONLY narrowing needed here — cluster confinement already happened.</b>
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> kept only the node's own cluster's ACL rows
/// (<c>ClusterFilterMode.ScopeTo</c>, matched <see cref="StringComparison.OrdinalIgnoreCase"/>),
/// so this snapshot cannot contain another cluster's grant. <c>ConfigDbApiKeyStore</c> reads the
/// fleet-wide ConfigDb and therefore has to apply that confinement itself; the two stores agree on
/// the outcome, not on the mechanism. An earlier version of this comment claimed
/// <c>ConfigDbApiKeyStore</c> "filters the same way in SQL" — it did not filter on cluster at all,
/// which was the cross-cluster over-grant this remark now exists to keep straight.
/// </remarks>
private static IReadOnlyList<NodeAcl> ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal)
{
var all = snapshot.GrantsFor(principal);
if (all.Count == 0) return [];
var scoped = new List<NodeAcl>(all.Count);
foreach (var grant in all)
{
if (grant.ScopeKind == NodeAclScopeKind.Cluster) scoped.Add(grant);
}
return scoped;
}
Task.FromResult(_authority.Current.GrantsFor(principal));
}
@@ -104,24 +104,34 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
if (key is null) return null;
return new ApiKeyLookup(key, await LoadClusterGrantsAsync(db, key.Principal, ct).ConfigureAwait(false));
return new ApiKeyLookup(key, await LoadGrantsAsync(db, key.Principal, ct).ConfigureAwait(false));
}
/// <inheritdoc />
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
return await LoadGrantsAsync(db, principal, ct).ConfigureAwait(false);
}
private async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
/// <summary>Loads a principal's grants, confined to this node's cluster.</summary>
/// <remarks>
/// <b>The full grant set, not just the <c>Cluster</c>-scoped rows</b> (#520 stage 5). The old
/// narrowing existed because only cluster-scoped rows could confer an interim role, but
/// <see cref="ApiKeyInterimRoleMapper"/> applies that same filter itself, so it was redundant even
/// then — and once scope is enforced it becomes a trap, silently truncating what
/// <c>ApiKeyLookup.Grants</c> claims to be. Removing it changes no role.
/// </remarks>
/// <param name="db">The config database.</param>
/// <param name="principal">The principal to load grants for.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The principal's grants in this node's cluster.</returns>
private async Task<IReadOnlyList<NodeAcl>> LoadGrantsAsync(
OtOpcUaConfigDbContext db, string principal, CancellationToken ct)
{
// Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
// those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
var rows = await db.NodeAcls
.AsNoTracking()
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
.Where(a => a.Principal == principal)
.ToListAsync(ct)
.ConfigureAwait(false);
@@ -36,6 +36,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly IHistorianDataSource _historianDataSource;
private readonly IDataPlaneAuthoritySource _dataPlaneAuthority;
private readonly DataPlaneAuthorizer? _dataPlaneAuthorizer;
private readonly ServerHistorianOptions _serverHistorianOptions;
private readonly Func<ActorSystem> _actorSystemAccessor;
private readonly ActorRegistry _actorRegistry;
@@ -73,6 +74,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// <param name="actorRegistry">The Akka.Hosting actor registry, used to resolve the local
/// <c>DriverHostActor</c> ref (<c>DriverHostActorKey</c>) the inbound node-write router Asks. Resolved
/// in <see cref="StartAsync"/> after the runtime actors have been registered.</param>
/// <param name="dataPlaneAuthorizer">#520 stage 5 — the node-scoped ACL gate. Nullable, and null means
/// "not wired" rather than "deny": an unregistered gate must not lock a node out of writes to protect a
/// feature that is off by default. Once wired AND enabled, every unresolvable case inside it denies.</param>
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
public OtOpcUaServerHostedService(
IOptions<OpcUaApplicationHostOptions> options,
@@ -84,8 +88,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
IConfiguration configuration,
Func<ActorSystem> actorSystemAccessor,
ActorRegistry actorRegistry,
DataPlaneAuthorizer? dataPlaneAuthorizer,
ILoggerFactory loggerFactory)
{
_dataPlaneAuthorizer = dataPlaneAuthorizer;
_options = options.Value;
_deferredSink = deferredSink;
_deferredServiceLevel = deferredServiceLevel;
@@ -225,12 +231,22 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
_server.SetHistorianDataSource(_historianDataSource);
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. Nothing reads
// them until the stage 5/6 enforcement seams land, so this changes no behaviour today; it exists
// because the node manager had NO cluster identity, and NodeScope.ClusterId is required while the
// evaluator fails closed on a mismatch — every scope it could have built would have been denied.
// #520 stage 4 — hand the node manager the node's data-plane authorization inputs. It exists because
// the node manager had NO cluster identity, and NodeScope.ClusterId is required while the evaluator
// fails closed on a mismatch — every scope it could have built would have been denied.
_server.SetDataPlaneAuthority(_dataPlaneAuthority);
// #520 stage 5 — hand it the node-scoped ACL gate. Still behaviour-neutral unless
// Security:Acl:Enabled is set: the gate reports itself disabled and every seam keeps its
// server-wide role check.
_server.SetDataPlaneAuthorizer(_dataPlaneAuthorizer);
if (_dataPlaneAuthorizer is { Enabled: true })
{
_logger.LogWarning(
"OtOpcUaServerHostedService: node ACL enforcement is ENABLED — writes, alarm commands and "
+ "HistoryRead now require a matching NodeAcl grant. Read, Browse and Subscribe remain ungated.");
}
// Propagate the configured tie-cluster over-fetch bound onto the node manager so HistoryRead-Raw
// can page WITHIN an oversized tie cluster (more samples sharing one SourceTimestamp than the page
// cap) rather than failing the read. The option's default (65536) survives when the ServerHistorian
@@ -274,6 +290,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_server?.SetHistorianDataSource(null);
// Restore the empty authority so a late session activation cannot authorize against a stopping node.
_server?.SetDataPlaneAuthority(null);
// Detach the ACL gate too. Leaving it attached alongside an emptied authority would deny every late
// request rather than let the seams fall back to their role checks — a stopping node should shed
// enforcement in the same order it acquired it.
_server?.SetDataPlaneAuthorizer(null);
return Task.CompletedTask;
}
@@ -138,6 +138,9 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a
// startup error.
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
// #520 stage 5 — Security:Acl:Enabled. Default false; see AclOptions for why the flip must be a config
// change rather than a redeploy, and what it swaps rather than adds.
builder.Services.Configure<AclOptions>(builder.Configuration.GetSection(AclOptions.SectionName));
if (hasAdmin)
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
@@ -360,6 +363,14 @@ if (hasDriver)
builder.Services.AddSingleton<DataPlaneAuthorityState>();
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
builder.Services.AddSingleton<IDataPlaneAuthoritySink>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
// #520 stage 5 — the tries the ACL gate evaluates against are taken FROM the authority state, not
// registered independently. The state builds the trie inside the same Apply() call that installs
// the snapshot, so resolving any other cache here would hand the evaluator a permanently empty
// one — and an empty trie denies everything, which looks exactly like "the ACLs are working" until
// someone checks that an ALLOW is possible at all. Deriving it from the owner makes that mistake
// unrepresentable.
builder.Services.AddSingleton(sp => sp.GetRequiredService<DataPlaneAuthorityState>().Tries);
}
else
{
@@ -369,8 +380,24 @@ if (hasDriver)
// fix, as the NullOpcUaUserAuthenticator registration below. Only the SINK is genuinely optional
// (nothing publishes artifacts on a node with no driver role).
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
// Nothing installs a trie on a node with no driver role; an empty cache denies every scoped
// decision, which is correct for a node that serves no data plane.
builder.Services.AddSingleton<PermissionTrieCache>();
}
// #520 stage 5 — the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
// OtOpcUaServerHostedService takes it as a constructor parameter and the built-in container resolves
// constructors by whether every parameter is registered, not by whether the parameter type is nullable.
// That is the same trap the IOpcUaUserAuthenticator and IDataPlaneAuthoritySource registrations above
// both carry a comment about; an absent registration is a node that will not start.
builder.Services.AddSingleton<IPermissionEvaluator>(sp =>
new TriePermissionEvaluator(sp.GetRequiredService<PermissionTrieCache>()));
builder.Services.AddSingleton(sp => new DataPlaneAuthorizer(
sp.GetRequiredService<IDataPlaneAuthoritySource>(),
sp.GetRequiredService<IPermissionEvaluator>(),
sp.GetRequiredService<IOptions<AclOptions>>().Value.Enabled,
sp.GetRequiredService<ILoggerFactory>().CreateLogger<DataPlaneAuthorizer>()));
if (hasAdmin)
{
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
@@ -335,9 +335,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
return;
}
args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
token.UserName, string.Join(",", result.Roles));
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, result.ApiKeyId);
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} as {Principal} ({Roles})",
token.UserName, result.Principal, string.Join(",", result.Roles));
}
/// <summary>
@@ -394,7 +394,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
return;
}
args.Identity = new RoleCarryingUserIdentity(token, result.Roles);
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, apiKeyId: null);
logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})",
OpcUaPrincipals.Anonymous,
result.Roles.Count == 0 ? "no roles" : string.Join(",", result.Roles));
@@ -276,6 +276,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private volatile IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
/// <summary>
/// The node-scoped ACL gate (#520 stage 5), or <c>null</c> when none is wired. Consulted by the
/// write, alarm-command and HistoryRead seams <b>only while
/// <see cref="Security.DataPlaneAuthorizer.Enabled"/> is true</b>; otherwise each keeps its
/// server-wide role check and behaviour is identical to before this stage.
/// </summary>
/// <remarks>
/// Null means "not wired" and is treated as not-enabled, not as deny-all. That looks like the wrong
/// direction for a security control, but the alternative denies every write on any node that has
/// not been given an authorizer — including every existing test host and the admin-only nodes — to
/// protect a feature that is off by default. The real fail-closed behaviour lives one level in:
/// once an authorizer IS wired and enabled, every unresolvable case denies.
/// </remarks>
public DataPlaneAuthorizer? Authorizer
{
get => _authorizer;
set => _authorizer = value;
}
private volatile DataPlaneAuthorizer? _authorizer;
/// <summary>
/// True when a wired authorizer is switched on — i.e. when ACL grants, rather than the server-wide
/// role set, decide the gated operations.
/// </summary>
private bool AclEnforced => _authorizer is { Enabled: true };
/// <summary>
/// The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> uses to page WITHIN an
/// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
@@ -1181,17 +1208,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
// role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
{
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
// state change and surfaces the status to the client; we never route or mutate.
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
var gate = EvaluateAlarmCommandGate(identity, condition, operation);
if (gate is not null) return gate;
var cmd = new AlarmCommand(
AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
Operation: operation,
User: identity.DisplayName ?? string.Empty,
User: AlarmOperatorName(identity!),
Comment: comment?.Text,
UnshelveAtUtc: unshelveAt);
@@ -1223,10 +1246,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
// contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route).
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
{
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
var gate = EvaluateAlarmCommandGate(identity, condition, "Acknowledge");
if (gate is not null) return gate;
// Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
// for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
@@ -1235,12 +1256,72 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
Comment: comment?.Text,
OperatorUser: identity.DisplayName ?? string.Empty));
OperatorUser: AlarmOperatorName(identity!)));
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
return ServiceResult.Good;
}
/// <summary>
/// The one authorization gate both alarm-command paths share — scripted
/// (<see cref="HandleAlarmCommand"/>) and native (<see cref="HandleNativeAlarmAck"/>).
/// </summary>
/// <remarks>
/// <para>
/// Extracted in #520 stage 5 because the two had a copy of the same role check and were about
/// to acquire a copy of the same ACL check. They must not drift: an ack that the scripted path
/// refuses and the native path allows is a hole whose shape depends on which kind of alarm the
/// operator happened to click.
/// </para>
/// <para>
/// With ACLs enforced, the Part 9 <c>Acknowledge</c> / <c>Confirm</c> / shelve calls map onto
/// their own <see cref="OpcUaOperation"/> members, so a grant CAN distinguish them — unlike the
/// role gate this replaces, where one <c>AlarmAck</c> role covered all three. Anything not
/// recognised as confirm or shelve is treated as an acknowledge, which is the most restrictive
/// reading available: <c>AlarmAcknowledge</c> is the permission an operator authoring "can this
/// principal act on alarms at all" grants first.
/// </para>
/// </remarks>
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
/// <param name="condition">The condition the method targets.</param>
/// <param name="operation">The Part 9 operation name.</param>
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
private ServiceResult? EvaluateAlarmCommandGate(
RoleCarryingUserIdentity? identity, ConditionState condition, string operation)
{
if (AclEnforced)
{
return _authorizer!.Authorize(
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId));
}
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
// state change and surfaces the status to the client; we never route or mutate.
return identity is null
|| !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase)
? new ServiceResult(StatusCodes.BadUserAccessDenied)
: null;
}
/// <summary>Maps a Part 9 method name onto the permission it requires.</summary>
/// <param name="operation">The Part 9 operation name.</param>
/// <returns>The operation to authorize.</returns>
internal static OpcUaOperation AlarmOperationFor(string operation) => operation switch
{
"Confirm" => OpcUaOperation.AlarmConfirm,
"OneShotShelve" or "TimedShelve" or "Unshelve" => OpcUaOperation.AlarmShelve,
_ => OpcUaOperation.AlarmAcknowledge,
};
/// <summary>
/// The operator name recorded on an alarm command. Prefers the ACL principal over the SDK display
/// name, which for a key-bearing client is the static username every key presents.
/// </summary>
/// <param name="identity">The authenticated identity.</param>
/// <returns>The name to record.</returns>
private static string AlarmOperatorName(RoleCarryingUserIdentity identity) =>
identity.Principal.Length > 0 ? identity.Principal : identity.DisplayName ?? string.Empty;
/// <summary>
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
/// <see cref="EnsureVariable"/>. The OPC UA SDK invokes it when a client writes the
@@ -1284,7 +1365,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
var gateway = _nodeWriteGateway;
var gate = EvaluateEquipmentWriteGate(identity, gateway is not NullOpcUaNodeWriteGateway);
var gatewayWired = gateway is not NullOpcUaNodeWriteGateway;
// #520 stage 5. With ACLs enforced the scoped decision REPLACES the server-wide WriteOperate role
// check — see AclOptions for why chaining them would leave every narrow grant inert. The
// gateway-wired check applies either way: it is an availability fact, not an authorization one.
var gate = AclEnforced
? _authorizer!.Authorize(identity, OpcUaOperation.WriteOperate, node.NodeId, RealmOf(node.NodeId))
?? (gatewayWired ? null : new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable"))
: EvaluateEquipmentWriteGate(identity, gatewayWired);
if (gate is not null) return gate;
// Item A (synchronous structural fail-fast): reject a structurally-invalid write INLINE — return Bad
@@ -1314,7 +1403,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// event can populate ClientUserId. Resolved here off the same identity the gate used (null when the
// session is anonymous / carries no role-carrying identity — the gate would already have vetoed, so in
// practice non-null on this path, but kept defensive).
var clientUserId = identity?.DisplayName;
// Prefer the ACL principal: for a key-bearing client DisplayName is the static username every key
// presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart.
var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName;
// Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock
// continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never
@@ -2520,6 +2611,67 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// backend throw) never throws out of the batch.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Drops the handles in a HistoryRead batch the calling session is not granted
/// <see cref="OpcUaOperation.HistoryRead"/> on, marking each denied slot
/// <c>BadUserAccessDenied</c> (#520 stage 5).
/// </summary>
/// <remarks>
/// <para>
/// <b>Until this stage HistoryRead carried no authorization check whatsoever</b> — any session
/// that could open a connection could read the full history of every historized node. It was
/// not a regression; it had simply never been gated, because the only two gates in the server
/// were the write and alarm role checks.
/// </para>
/// <para>
/// Denial is <b>per node, not per batch</b>: OPC UA Part 11 gives every node in a HistoryRead
/// its own result slot, and a client reading ten nodes of which one is off-limits should get
/// nine answers and one denial rather than a whole failed request. Failing the batch would also
/// leak more than it protects — the client learns exactly which read was refused either way,
/// but a batch failure additionally denies data it was entitled to.
/// </para>
/// <para>
/// Returns the original list unchanged when enforcement is off, so the non-ACL path allocates
/// nothing and behaves exactly as before.
/// </para>
/// </remarks>
/// <param name="context">The SDK context carrying the session identity.</param>
/// <param name="nodesToProcess">The pre-filtered handles the base dispatcher built.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <returns>The handles that may proceed.</returns>
private List<NodeHandle> AuthorizeHistoryRead(
ServerSystemContext context,
List<NodeHandle> nodesToProcess,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors)
{
if (!AclEnforced) return nodesToProcess;
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
var allowed = new List<NodeHandle>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
var veto = _authorizer!.Authorize(
identity, OpcUaOperation.HistoryRead, handle.NodeId, RealmOf(handle.NodeId));
if (veto is null)
{
allowed.Add(handle);
continue;
}
// Set both slots explicitly rather than relying on the base's pre-seeded
// BadHistoryOperationUnsupported — "unsupported" would tell the client the node has no history,
// which is a different fact from "you may not read it" and sends them debugging the wrong thing.
errors[handle.Index] = veto;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied };
}
return allowed;
}
/// <inheritdoc />
protected override void HistoryReadRawModified(
ServerSystemContext context,
@@ -2532,6 +2684,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
IDictionary<NodeId, NodeState> cache)
{
var session = context.OperationContext?.Session;
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
@@ -2569,6 +2722,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
@@ -2618,6 +2772,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Snapshot the requested timestamps once — the same list is read for every node.
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
@@ -2649,6 +2804,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Snapshot the select clauses once — the same filter projects every node's events.
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
@@ -3,6 +3,7 @@ using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -126,6 +127,22 @@ public sealed class OtOpcUaSdkServer : StandardServer
return true;
}
/// <summary>
/// Wire the node-scoped ACL gate (#520 stage 5) onto the created <see cref="OtOpcUaNodeManager"/>.
/// The host calls this after start; passing <c>null</c> detaches it, which returns the write and
/// alarm seams to their server-wide role checks and leaves HistoryRead ungated. No-op (returns
/// <c>false</c>) when the node manager has not been created yet, mirroring
/// <see cref="SetDataPlaneAuthority"/>.
/// </summary>
/// <param name="authorizer">The gate, or <c>null</c> to detach.</param>
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when none exists yet.</returns>
public bool SetDataPlaneAuthorizer(DataPlaneAuthorizer? authorizer)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.Authorizer = authorizer;
return true;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
@@ -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; }
}
@@ -536,26 +536,33 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
// unresolvedScopes counts grants narrower than Cluster whose ScopeId named nothing in this
// artifact — a dangling id, or a scope kind with no v3 entity behind it. Those grants will be
// dropped when the trie is built, so surfacing the count here is the operator's only warning
// that a rule they authored is not going to do anything.
var unresolvedScopes = projection.Grants
.Where(g => g.ScopeKind != NodeAclScopeKind.Cluster && !string.IsNullOrEmpty(g.ScopeId))
.Select(g => new AclScopeRef(g.ScopeKind, g.ScopeId!))
.Distinct()
.Count(r => !projection.ScopePaths.ContainsKey(r));
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12],
projection.Keys.Count, projection.Grants.Count, projection.ScopePaths.Count);
if (unresolvedScopes > 0)
// Reported BY the trie build (#520 stage 5), not re-derived here. The previous version of this
// log counted unresolvable ScopeIds itself and so agreed with the builder on exactly one of its
// four drop reasons — a row with a blank ScopeId, a retired scope kind, or a scope path that
// does not start at a realm all vanished with the log still reading zero. A dropped row is an
// authored rule that can never match, and this is the operator's only warning of it.
if (snapshot.DroppedGrants.Count > 0)
{
_log.Warning(
"OpcUaPublish: {Unresolved} ACL scope(s) in generation {Generation} name nothing in the deployed address space — grants on them will be DROPPED, not applied. Check the ScopeId on those rules",
unresolvedScopes, snapshot.GenerationId);
"OpcUaPublish: {Dropped} ACL rule(s) in generation {Generation} were DROPPED and will never match — {Detail}",
snapshot.DroppedGrants.Count, snapshot.GenerationId,
string.Join("; ", snapshot.DroppedGrants
.Select(d => $"acl {d.NodeAclId} ({d.ScopeKind} '{d.ScopeId}') {d.Reason}")));
}
if (string.IsNullOrWhiteSpace(projection.ClusterId) && projection.Grants.Count > 0)
{
// No cluster identity means no trie was built at all, so nothing can be granted here. Say
// so explicitly: with enforcement on, this node denies every write, alarm command and
// HistoryRead, and the grant count logged above would otherwise suggest the opposite.
_log.Warning(
"OpcUaPublish: this node has no cluster identity, so NO permission trie was built for its {Grants} grant(s) — with Security:Acl:Enabled every scoped decision here will be denied",
projection.Grants.Count);
}
}
catch (Exception ex)