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; } }