Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs
T
Joseph Doherty a438ac1640 feat(acl): keys + ACLs reach driver-only nodes via the artifact (#520 stage 4)
A driver-only node has had no ConfigDb since per-cluster mesh Phase 4, so it
registered NullOpcUaUserAuthenticator and denied every UserName token. Its keys
now ride the deployment artifact instead.

- ConfigComposer snapshots ApiKeys alongside the NodeAcls it already carried.
  Only the peppered HMAC ships, never a secret, and the pepper is node config
  that is deliberately not in the artifact.
- DeploymentArtifact.ParseDataPlaneAuthority decodes both node-side. Grants are
  scoped to the node's own cluster; keys are not, because ApiKey carries no
  ClusterId — a key is a fleet-wide credential and the ACL rows are what bind it.
- OpcUaPublishActor installs them on DataPlaneAuthorityState on every apply,
  BEFORE its plan.IsEmpty early-return: an artifact that only revokes a key
  produces an empty address-space plan, so feeding after that return would leave
  the revoked key working until an unrelated structural change redeployed.
- ArtifactApiKeyStore serves them to session activation; a node with a ConfigDb
  keeps ConfigDbApiKeyStore, whose per-request read is what makes revocation
  immediate there.
- OtOpcUaNodeManager gains DataPlaneAuthority — the cluster identity blocker #4
  named. NodeScope.ClusterId is required and the evaluator fails closed on a
  mismatch, so until now no scope it built could have evaluated. Nothing reads it
  until stages 5/6.

The generation is a node-local COUNTER driven by the RevisionHash, not derived
from its bits. PermissionTrieCache treats the highest GenerationId as current, so
a generation taken from hash bytes would install a lower number about half the
time and the cache would go on serving the previous deployment's ACLs, silently.

Sessions are not stamped with it yet — that belongs with the first seam that
reads a UserAuthorizationState, in stage 5, rather than being built now and
consulted by nothing.

Live-gated on docker-dev against site-a-1 (driver-only, no ConfigDb): key
authenticates, wrong secret denied, both halves of the revocation asymmetry
observed, and a SITE-A grant confirmed not to reach site-b-1. Key minted with an
independent Python HMAC so the verifier was checked against something other than
itself. Rig left clean.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 14:37:18 -04:00

68 lines
3.0 KiB
C#

using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// <see cref="IApiKeyStore"/> reading the config database directly (#520, stage 2). Usable on a node
/// that has a ConfigDb — i.e. one carrying the <c>admin</c> role, including a fused admin+driver node.
/// </summary>
/// <remarks>
/// <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot be
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead (#520 stage 4).
/// <para>
/// Reading per request is what makes revocation immediate here, which is why this store is
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStore : IApiKeyStore
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _factory;
/// <summary>Initializes a new instance of the <see cref="ConfigDbApiKeyStore"/> class.</summary>
/// <param name="factory">The config DB context factory.</param>
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory) => _factory = factory;
/// <inheritdoc />
public async Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
// Enabled + expiry are filtered in SQL rather than in memory so a disabled key never
// materialises into a process where something might later treat it as a match.
var key = await db.ApiKeys
.AsNoTracking()
.SingleOrDefaultAsync(
k => k.ApiKeyId == apiKeyId
&& k.Enabled
&& (k.ExpiresUtc == null || k.ExpiresUtc > utcNow),
ct)
.ConfigureAwait(false);
if (key is null) return null;
return new ApiKeyLookup(key, await LoadClusterGrantsAsync(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);
}
private static async Task<IReadOnlyList<NodeAcl>> LoadClusterGrantsAsync(
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.
return await db.NodeAcls
.AsNoTracking()
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
.ToListAsync(ct)
.ConfigureAwait(false);
}
}