Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs
T

168 lines
9.2 KiB
C#

using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Cluster;
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 activation is what makes revocation prompt here, which is why this store is
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node. ⚠️ It reaches a
/// <b>new session activation</b> only: an already-open session re-checks its key against the
/// in-memory artifact snapshot on every operation, not against this store, so clearing
/// <c>Enabled</c> here does not stop an open session until the next deployment. See
/// <c>docs/security.md</c>.
/// </para>
/// <para>
/// ⚠️ <b>In production this store is WRAPPED by <see cref="BoundedApiKeyStore"/></b> (arch-review
/// 03/S18), because these queries run inline in the SDK's impersonation callback under a
/// SessionManager-wide lock. A call may therefore be cancelled by the wrapper's deadline even
/// though the caller passed <see cref="CancellationToken.None"/> — the resulting
/// <see cref="OperationCanceledException"/> is a denial, not a bug.
/// </para>
/// <para>
/// ⚠️ <b>The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's.</b> That is
/// the difference from <see cref="ArtifactApiKeyStore"/>, whose snapshot was already narrowed to
/// the node's own cluster by <c>DeploymentArtifact.ParseDataPlaneAuthority</c>. So this store must
/// confine grants to <see cref="ClusterId"/> itself — without that predicate a Cluster-scoped
/// grant authored in ANY cluster in the fleet conferred an interim server-wide role here.
/// </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>
/// <param name="clusterId">
/// This node's own ClusterId, from <see cref="ResolveClusterId"/>. <see langword="null"/> on a
/// legacy / single-cluster node that carries no <c>cluster-{ClusterId}</c> role — see
/// <see cref="ClusterId"/> for what that means for filtering.
/// </param>
public ConfigDbApiKeyStore(IDbContextFactory<OtOpcUaConfigDbContext> factory, string? clusterId)
{
ArgumentNullException.ThrowIfNull(factory);
_factory = factory;
ClusterId = string.IsNullOrWhiteSpace(clusterId) ? null : clusterId;
}
/// <summary>
/// The node's own ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.
/// </summary>
/// <remarks>
/// A <see langword="null"/> ClusterId applies NO cluster filter, mirroring the artifact path's
/// <c>ClusterFilterMode.None</c> — a single-cluster artifact is applied whole. Failing closed here
/// instead would silently strip every grant on an existing single-cluster deployment, which is a
/// worse failure than the one being fixed and would not be visible until a client was denied.
/// </remarks>
public string? ClusterId { get; }
/// <summary>
/// Resolves the node's ClusterId from its OWN configured roles, e.g. <c>cluster-SITE-A</c> →
/// <c>SITE-A</c>; <see langword="null"/> when no role is cluster-scoped.
/// </summary>
/// <remarks>
/// <para>
/// This is the same derivation <see cref="Cluster.ClusterRoleInfo"/> performs for
/// <c>IClusterRoleInfo.ClusterId</c> — literally so as of Gitea #532, which collapsed four
/// open-coded copies into <see cref="RoleParser.ClusterIdFromRoles"/>. The copies had drifted:
/// three classified the cluster role case-sensitively and one did not, and only one applied
/// the <c>OTOPCUA_ROLES</c> fallback. On a node that hit either gap this method returned
/// <see langword="null"/>, and a null ClusterId applies no filter at all — so the
/// cross-cluster over-grant this store exists to prevent failed <b>open</b>.
/// </para>
/// <para>
/// ⚠️ <b>Takes <see cref="AkkaClusterOptions"/> (pure configuration), NOT
/// <c>IClusterRoleInfo</c> resolved from the service provider.</b> <c>ClusterRoleInfo</c>
/// depends on the <c>ActorSystem</c>; resolving it during host composition recurses into
/// ActorSystem construction and stack-overflows the node at boot while compiling clean (see
/// CLAUDE.md, per-cluster mesh Phase 6). The ClusterId is configuration, so read it from
/// configuration.
/// </para>
/// </remarks>
/// <param name="options">The node's bound cluster options.</param>
/// <returns>The node's ClusterId, or <see langword="null"/> when it carries no cluster-scoped role.</returns>
public static string? ResolveClusterId(AkkaClusterOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return RoleParser.ClusterIdFromRoles(options.Roles);
}
/// <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 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 LoadGrantsAsync(db, principal, ct).ConfigureAwait(false);
}
/// <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)
{
var rows = await db.NodeAcls
.AsNoTracking()
.Where(a => a.Principal == principal)
.ToListAsync(ct)
.ConfigureAwait(false);
if (ClusterId is null) return rows;
// The ClusterId match is done HERE rather than as a SQL predicate, unlike the Enabled/expiry
// filters above, because it must be case-insensitive DETERMINISTICALLY. `a.ClusterId == ...` in
// SQL is decided by the database collation — case-insensitive on the usual SQL Server default,
// case-SENSITIVE on a CS collation or on the in-memory provider the tests use — and the two
// spellings genuinely differ: `Cluster:Roles` binds a role verbatim (cluster-SITE-A ⇒ SITE-A)
// while the OTOPCUA_ROLES path goes through RoleParser.Parse, which lower-cases it (⇒ site-a).
// A collation-dependent match would therefore strip every grant on a node configured the second
// way. OrdinalIgnoreCase is the SAME comparison DeploymentArtifact.ParseDataPlaneAuthority uses
// to scope the artifact's grants, so both stores confine identically.
var mine = new List<NodeAcl>(rows.Count);
foreach (var grant in rows)
{
if (string.Equals(grant.ClusterId, ClusterId, StringComparison.OrdinalIgnoreCase)) mine.Add(grant);
}
return mine;
}
}