Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ArtifactApiKeyStore.cs
T
Joseph Doherty f692f6afe4 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
2026-08-01 05:00:28 -04:00

79 lines
4.2 KiB
C#

using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
// Fully-qualified rather than relative: this file's own namespace root contains a `Configuration`
// namespace (ZB.MOM.WW.OtOpcUa.Host.Configuration), so a relative `Configuration.Enums` binds there and
// fails to compile.
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// <see cref="IApiKeyStore"/> served from the deployment artifact (#520, stage 4). This is the key
/// source for a <b>driver-only</b> node, which has had no ConfigDb since per-cluster mesh Phase 4 and
/// until now denied every UserName token.
/// </summary>
/// <remarks>
/// <para>
/// Purely in-memory: <see cref="OpcUaPublishActor"/> decodes the keys and grants once per applied
/// artifact and installs them on <see cref="IDataPlaneAuthoritySource"/>. That matters because the
/// SDK raises the impersonation callback <b>synchronously, under a <c>SessionManager</c>-wide
/// lock</b> — an I/O-bound lookup here would serialize every session activation in the server
/// behind one disk or network round trip.
/// </para>
/// <para>
/// ⚠️ <b>Consequence: revocation lands on the next deployment, not immediately.</b> A node reading
/// the ConfigDb sees <c>Enabled = 0</c> on the next request; this one sees it only once a new
/// artifact arrives. That asymmetry is inherent to a node with no config database and is
/// documented for operators on the AdminUI key page.
/// </para>
/// <para>
/// Before the first artifact is applied the snapshot is empty, so every key is denied. That is the
/// 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
{
private readonly IDataPlaneAuthoritySource _authority;
/// <summary>Initializes a new instance of the <see cref="ArtifactApiKeyStore"/> class.</summary>
/// <param name="authority">The node's current data-plane authorization inputs.</param>
public ArtifactApiKeyStore(IDataPlaneAuthoritySource authority)
{
ArgumentNullException.ThrowIfNull(authority);
_authority = authority;
}
/// <inheritdoc />
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
// Read the snapshot ONCE. Re-reading Current for the grants would let a deploy land between the
// two reads and pair a key from one generation with grants from another.
var snapshot = _authority.Current;
var key = snapshot.FindUsableKey(apiKeyId, utcNow);
return Task.FromResult(key is null
? null
: new ApiKeyLookup(key, snapshot.GrantsFor(key.Principal)));
}
/// <inheritdoc />
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult(_authority.Current.GrantsFor(principal));
}