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 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
/// new session activation only: an already-open session re-checks its key against the
/// in-memory artifact snapshot on every operation, not against this store, so clearing
/// Enabled here does not stop an open session until the next deployment. See
/// docs/security.md.
///
///
/// ⚠️ In production this store is WRAPPED by (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 — the resulting
/// is a denial, not a bug.
///
///
/// ⚠️ 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 — literally so as of Gitea #532, which collapsed four
/// open-coded copies into . The copies had drifted:
/// three classified the cluster role case-sensitively and one did not, and only one applied
/// the OTOPCUA_ROLES fallback. On a node that hit either gap this method returned
/// , and a null ClusterId applies no filter at all — so the
/// cross-cluster over-grant this store exists to prevent failed open.
///
///
/// ⚠️ 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);
return RoleParser.ClusterIdFromRoles(options.Roles);
}
///
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 LoadGrantsAsync(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 LoadGrantsAsync(db, principal, ct).ConfigureAwait(false);
}
/// Loads a principal's grants, confined to this node's cluster.
///
/// The full grant set, not just the Cluster-scoped rows (#520 stage 5). The old
/// narrowing existed because only cluster-scoped rows could confer an interim role, but
/// applies that same filter itself, so it was redundant even
/// then — and once scope is enforced it becomes a trap, silently truncating what
/// ApiKeyLookup.Grants claims to be. Removing it changes no role.
///
/// The config database.
/// The principal to load grants for.
/// The cancellation token.
/// The principal's grants in this node's cluster.
private async Task> 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(rows.Count);
foreach (var grant in rows)
{
if (string.Equals(grant.ClusterId, ClusterId, StringComparison.OrdinalIgnoreCase)) mine.Add(grant);
}
return mine;
}
}