168 lines
9.2 KiB
C#
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;
|
|
}
|
|
}
|