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;
///
/// reading the config database directly (#520, stage 2). Usable on a node
/// that has a ConfigDb — i.e. one carrying the admin role, including a fused admin+driver node.
///
///
/// A driver-only node has no ConfigDb since per-cluster mesh Phase 4, so this store cannot be
/// registered there; that node uses instead (#520 stage 4).
///
/// 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.
///
///
/// ⚠️ The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's. That is
/// the difference from , whose snapshot was already narrowed to
/// the node's own cluster by DeploymentArtifact.ParseDataPlaneAuthority. So this store must
/// confine grants to itself — without that predicate a Cluster-scoped
/// grant authored in ANY cluster in the fleet conferred an interim server-wide role here.
///
///
public sealed class ConfigDbApiKeyStore : IApiKeyStore
{
private readonly IDbContextFactory _factory;
/// Initializes a new instance of the class.
/// The config DB context factory.
///
/// This node's own ClusterId, from . on a
/// legacy / single-cluster node that carries no cluster-{ClusterId} role — see
/// for what that means for filtering.
///
public ConfigDbApiKeyStore(IDbContextFactory factory, string? clusterId)
{
ArgumentNullException.ThrowIfNull(factory);
_factory = factory;
ClusterId = string.IsNullOrWhiteSpace(clusterId) ? null : clusterId;
}
///
/// The node's own ClusterId, or when it carries no cluster-scoped role.
///
///
/// A ClusterId applies NO cluster filter, mirroring the artifact path's
/// ClusterFilterMode.None — 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.
///
public string? ClusterId { get; }
///
/// Resolves the node's ClusterId from its OWN configured roles, e.g. cluster-SITE-A →
/// SITE-A; when no role is cluster-scoped.
///
///
///
/// This is the same derivation performs for
/// IClusterRoleInfo.ClusterId, reusing the same primitives —
/// first cluster-scoped role wins, as it does there.
///
///
/// ⚠️ Takes (pure configuration), NOT
/// IClusterRoleInfo resolved from the service provider. ClusterRoleInfo
/// depends on the ActorSystem; 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.
///
///
/// The node's bound cluster options.
/// The node's ClusterId, or when it carries no cluster-scoped role.
public static string? ResolveClusterId(AkkaClusterOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var clusterRole = options.Roles.FirstOrDefault(RoleParser.IsClusterRole);
return clusterRole is null ? null : RoleParser.ClusterIdFromRole(clusterRole);
}
///
public async Task 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));
}
///
public async Task> FindGrantsAsync(string principal, CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false);
}
private async Task> 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.
var rows = await db.NodeAcls
.AsNoTracking()
.Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster)
.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(rows.Count);
foreach (var grant in rows)
{
if (string.Equals(grant.ClusterId, ClusterId, StringComparison.OrdinalIgnoreCase)) mine.Add(grant);
}
return mine;
}
}