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