diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs new file mode 100644 index 00000000..0fa648d6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs @@ -0,0 +1,67 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// Derives the interim server-wide role set for an authenticated principal from its +/// grants (#520, stage 2). +/// +/// +/// +/// Why this exists at all. The two live authorization gates — +/// on Value writes and +/// on the Part 9 alarm methods — check a server-wide +/// role set on the session identity. Replacing LDAP with API keys therefore has to keep +/// producing that role set, or the cutover would lock every client out of writes and alarm acks +/// long before per-node ACL evaluation lands in stage 5. +/// +/// +/// ⚠️ Only grants confer a role. This is deliberate, +/// and it under-grants. The obvious implementation — union the permission flags across all of +/// a principal's grants — would let a grant authored narrowly ("WriteOperate on Equipment X") +/// confer the server-wide WriteOperate role, because nothing here can enforce scope yet. That +/// fails in the dangerous direction: the operator's narrow intent silently becomes broad +/// authority. Restricting to cluster-scoped grants means a narrow grant does nothing until +/// stage 5 can honour its scope, which is no worse than today (where every ACL row does +/// nothing) and never exceeds what was authored. +/// +/// +/// Scope-narrower-than-cluster grants are not an error and are not logged as one — they are +/// simply inert until enforcement lands. The AdminUI banner is where an operator is told that. +/// +/// +public static class ApiKeyInterimRoleMapper +{ + /// + /// Maps a principal's cluster-scoped grants to the server-wide data-plane roles. + /// + /// Candidate ACL rows. Rows for other principals are ignored. + /// The authenticated principal. + /// The distinct role names to attach to the session identity; never null. + public static IReadOnlyList Map(IEnumerable grants, string principal) + { + ArgumentNullException.ThrowIfNull(grants); + + if (string.IsNullOrWhiteSpace(principal)) return Array.Empty(); + + var effective = NodePermissions.None; + foreach (var grant in grants) + { + if (grant.ScopeKind != NodeAclScopeKind.Cluster) continue; + if (!string.Equals(grant.Principal, principal, StringComparison.OrdinalIgnoreCase)) continue; + effective |= grant.PermissionFlags; + } + + var roles = new List(2); + if (effective.HasFlag(NodePermissions.WriteOperate)) roles.Add(OpcUaDataPlaneRoles.WriteOperate); + + // AlarmAcknowledge is the flag an operator authors; AlarmAck is the role the Part 9 method gates + // check. The gate covers acknowledge/confirm/shelve as one role, so Confirm/Shelve do not map + // separately — mapping them would imply a granularity the gate does not have. + if (effective.HasFlag(NodePermissions.AlarmAcknowledge)) roles.Add(OpcUaDataPlaneRoles.AlarmAck); + + return roles; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs new file mode 100644 index 00000000..0415cc16 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// Production for the machine-to-machine data plane (#520): +/// the client presents the static username apikey with an +/// otua_<keyId>_<secret> credential as the password. Replaces the LDAP adapter. +/// +/// +/// +/// This runs inline on session activation. The SDK raises ImpersonateUser +/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not +/// stall one request — it serializes every session activation server-wide. That is why +/// verification is a single indexed lookup plus one HMAC, and why there is no directory call, +/// no retry, and no network I/O on this path at all. It is the main practical advantage of keys +/// over the LDAP bind this replaces. +/// +/// +/// Fail closed, and say little. Every rejection returns the same opaque message. The +/// reason is logged server-side but never returned, so a caller cannot distinguish "no such key" +/// from "wrong secret" from "key disabled" and use the server as an oracle. +/// +/// +public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator +{ + /// + /// Returned for every failure mode, deliberately uninformative. Public so a test can assert that + /// the failure modes really are indistinguishable, rather than restating the literal and passing + /// even if the implementation started differentiating them. + /// + public const string DenyMessage = "Invalid API key."; + + private readonly IApiKeyStore _store; + private readonly IOptions _options; + private readonly ILogger _log; + private readonly TimeProvider _time; + + /// Initializes a new instance of the class. + /// The API key source. + /// The Security:ApiKeys options. + /// The logger. + /// Time provider, injected so expiry is testable. + public ApiKeyOpcUaUserAuthenticator( + IApiKeyStore store, + IOptions options, + ILogger log, + TimeProvider? time = null) + { + _store = store; + _options = options; + _log = log; + _time = time ?? TimeProvider.System; + } + + /// + public async Task AuthenticateUserNameAsync( + string username, string password, CancellationToken ct) + { + var opts = _options.Value; + + // The username carries no information, but check it rather than ignore it: a client sending the + // wrong username gets an explicit denial it can diagnose, instead of "any username works". + if (!string.Equals(username, opts.Username, StringComparison.Ordinal)) + { + _log.LogInformation( + "ApiKeyAuth: denied — username {Presented} is not the configured {Expected}", + username, opts.Username); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + if (!ApiKeySecret.TryParse(password, out var apiKeyId, out var secret)) + { + _log.LogInformation("ApiKeyAuth: denied — credential is not a well-formed otua_ key"); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + var now = _time.GetUtcNow().UtcDateTime; + + ApiKeyLookup? lookup; + try + { + lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + // The contract forbids throwing: an exception here escapes into the SDK's session-activation + // path and surfaces as BadInternalError. A store outage denies, it does not crash. + _log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + if (lookup is null) + { + _log.LogInformation("ApiKeyAuth: denied — no enabled, unexpired key with id {ApiKeyId}", apiKeyId); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + bool verified; + try + { + verified = ApiKeySecret.Verify(secret, opts.Pepper, lookup.Key.SecretHash); + } + catch (ArgumentException ex) + { + // Thrown only when the pepper is unset. This is a deployment error, not a bad credential — + // log it loudly, because otherwise it presents as "every key is rejected" with no clue why. + _log.LogError(ex, + "ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is", + ApiKeyOptions.SectionName); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + if (!verified) + { + _log.LogInformation("ApiKeyAuth: denied — secret mismatch for key id {ApiKeyId}", apiKeyId); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal); + + _log.LogInformation( + "ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]", + lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles)); + + return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs new file mode 100644 index 00000000..d6e384bf --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// Security:ApiKeys — OPC UA data-plane API key settings (#520). The data plane is +/// machine-to-machine, so a consuming system presents a key rather than an LDAP user. +/// +public sealed class ApiKeyOptions +{ + /// The configuration section name. + public const string SectionName = "Security:ApiKeys"; + + /// + /// The static username every key-bearing client presents. The username carries no information — + /// the key identifies the principal — but OPC UA's UserName token requires one, so it is fixed + /// and checked rather than ignored, which keeps a typo an explicit denial instead of a silent + /// "any username works". + /// + public string Username { get; set; } = "apikey"; + + /// + /// Server-side pepper for the stored key hashes. Supply via Security__ApiKeys__Pepper or a + /// ${secret:} reference — never commit it. Rotating it invalidates every existing key. + /// + /// + /// Deliberately has no default. An empty pepper still hashes "successfully" and would produce a + /// working-looking deployment with none of the protection it believes it has, so + /// ApiKeySecret.ComputeHash throws on one and this stays empty to make that fire early. + /// + public string Pepper { get; set; } = string.Empty; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs new file mode 100644 index 00000000..f268f6f6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +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 and such a node has no key source until the artifact-backed store lands in +/// stage 4. Until then a driver-only node authenticates no UserName tokens at all — it falls to +/// , which denies every attempt. That is +/// the safe direction, and anonymous sessions (still permitted, and still unrestricted until +/// enforcement lands in stages 5–6) are unaffected. +/// +public sealed class ConfigDbApiKeyStore : IApiKeyStore +{ + private readonly IDbContextFactory _factory; + + /// Initializes a new instance of the class. + /// The config DB context factory. + public ConfigDbApiKeyStore(IDbContextFactory factory) => _factory = factory; + + /// + 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; + + // 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 grants = await db.NodeAcls + .AsNoTracking() + .Where(a => a.Principal == key.Principal + && a.ScopeKind == NodeAclScopeKind.Cluster) + .ToListAsync(ct) + .ConfigureAwait(false); + + return new ApiKeyLookup(key, grants); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs new file mode 100644 index 00000000..67cfb2c1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs @@ -0,0 +1,30 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// Source of API keys and their ACL grants for OPC UA session activation (#520). +/// +/// +/// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused +/// admin+driver node reads the config database directly; a driver-only node has no ConfigDb +/// at all since per-cluster mesh Phase 4, so its keys must come out of the deployment artifact. +/// Stage 2 ships the ConfigDb implementation; the artifact-backed one lands in stage 4. +/// +public interface IApiKeyStore +{ + /// + /// Looks up an enabled, unexpired key by its public id, together with the ACL grants naming that + /// key's principal. + /// + /// The public key id parsed from the presented credential. + /// Current UTC time, for the expiry check. + /// The cancellation token. + /// The key and its principal's grants, or null when no usable key matches. + Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct); +} + +/// An API key resolved for authentication, with the ACL grants naming its principal. +/// The matched key row. +/// ACL rows whose principal matches . +public sealed record ApiKeyLookup(ApiKey Key, IReadOnlyList Grants); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 51380bb8..2efba25b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -339,7 +339,28 @@ if (hasDriver) builder.Services.TryAddSingleton(); builder.Services.TryAddScoped(); builder.Services.TryAddScoped, OtOpcUaGroupRoleMapper>(); - builder.Services.AddSingleton(); + + // #520 stage 2 — the OPC UA DATA PLANE is machine-to-machine, so its identity is an API key + // (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP + // registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP. + // Only IOpcUaUserAuthenticator moves. + builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); + if (hasAdmin) + { + // ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin. + // A driver-only node therefore has no key source until the artifact-backed store lands in + // stage 4; it falls through to NullOpcUaUserAuthenticator, which denies every UserName token. + // Safe direction, and anonymous sessions are unaffected. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + else + { + // MUST be registered explicitly, not left absent: OtOpcUaServerHostedService takes + // IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so an unregistered seam is not a + // quiet fallback to deny-all — it fails DI and the node does not start at all. + builder.Services.AddSingleton(NullOpcUaUserAuthenticator.Instance); + } // Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart) // and let OtOpcUaServerHostedService consume the validated IOptions instance rather than diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs new file mode 100644 index 00000000..767ebacc --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs @@ -0,0 +1,221 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; + +/// +/// Covers the #520 stage-2 identity cutover: the API-key authenticator and the interim role +/// mapping it feeds the two live authorization gates from. +/// +public sealed class ApiKeyAuthenticationTests +{ + private const string Pepper = "unit-test-pepper"; + private const string Principal = "scadabridge-dcl"; + + // ---------- interim role mapping ---------- + + [Fact] + public void A_cluster_scoped_write_grant_confers_the_write_role() + { + var roles = ApiKeyInterimRoleMapper.Map( + [Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal); + + roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + } + + [Fact] + public void A_narrower_scoped_write_grant_confers_nothing_yet() + { + // THE load-bearing assertion of this stage. Nothing in the pipeline can honour a scope until + // stage 5, so honouring the FLAG alone would turn "WriteOperate on Equipment X" into + // server-wide write authority — the operator's narrow intent silently becoming broad. Under- + // granting is the only safe reading while scope is unenforceable. + foreach (var narrower in new[] + { + NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea, + NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace, + }) + { + var roles = ApiKeyInterimRoleMapper.Map( + [Grant(narrower, NodePermissions.WriteOperate)], Principal); + + roles.ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role"); + } + } + + [Fact] + public void A_grant_for_a_different_principal_is_ignored() + { + var other = Grant(NodeAclScopeKind.Cluster, NodePermissions.Admin); + other.Principal = "someone-else"; + + ApiKeyInterimRoleMapper.Map([other], Principal).ShouldBeEmpty(); + } + + [Fact] + public void Alarm_acknowledge_maps_to_the_alarm_role_and_read_only_maps_to_nothing() + { + ApiKeyInterimRoleMapper + .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge)], Principal) + .ShouldContain(OpcUaDataPlaneRoles.AlarmAck); + + // ReadOnly carries no gated permission — reads are ungated today, so it must confer no role + // rather than quietly granting one. + ApiKeyInterimRoleMapper + .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.ReadOnly)], Principal) + .ShouldBeEmpty(); + } + + [Fact] + public void Flags_union_across_several_cluster_grants() + { + var roles = ApiKeyInterimRoleMapper.Map( + [ + Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate), + Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge), + ], Principal); + + roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + roles.ShouldContain(OpcUaDataPlaneRoles.AlarmAck); + } + + [Fact] + public void Principal_matching_is_case_insensitive_but_not_prefix_based() + { + ApiKeyInterimRoleMapper + .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal.ToUpperInvariant()) + .ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + + // A principal that merely starts with the granted name must not inherit it. + ApiKeyInterimRoleMapper + .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal + "-readonly") + .ShouldBeEmpty(); + } + + // ---------- authenticator ---------- + + [Fact] + public async Task A_valid_key_authenticates_as_its_principal() + { + var (presented, store) = SetUp(NodePermissions.WriteOperate, NodeAclScopeKind.Cluster); + + var result = await Auth(store).AuthenticateUserNameAsync("apikey", presented, default); + + result.Success.ShouldBeTrue(); + result.DisplayName.ShouldBe(Principal); + result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + } + + [Fact] + public async Task The_wrong_username_is_denied() + { + var (presented, store) = SetUp(); + var result = await Auth(store).AuthenticateUserNameAsync("alice", presented, default); + result.Success.ShouldBeFalse(); + } + + [Fact] + public async Task A_wrong_secret_a_missing_key_and_a_malformed_credential_are_all_denied_identically() + { + var (presented, store) = SetUp(); + var auth = Auth(store); + + var wrongSecret = await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", default); + var noSuchKey = await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", default); + var malformed = await auth.AuthenticateUserNameAsync("apikey", "not-a-key", default); + var empty = await auth.AuthenticateUserNameAsync("apikey", "", default); + + foreach (var r in new[] { wrongSecret, noSuchKey, malformed, empty }) + { + r.Success.ShouldBeFalse(); + // Identical message for every failure mode: the server must not be usable as an oracle for + // which half of the credential was wrong, or whether a key id exists at all. + r.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage); + } + + // ...and the valid one still works, so the above is not passing because everything is denied. + (await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeTrue(); + } + + [Fact] + public async Task A_store_that_throws_denies_rather_than_propagating() + { + // The interface contract forbids throwing — an exception escapes into the SDK session-activation + // path and surfaces as BadInternalError instead of a clean rejection. + var store = new ThrowingApiKeyStore(); + var result = await Auth(store).AuthenticateUserNameAsync("apikey", "otua_aabbccdd_secret", default); + + result.Success.ShouldBeFalse(); + result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage); + } + + [Fact] + public async Task An_unset_pepper_denies_rather_than_authenticating_everything() + { + var (presented, store) = SetUp(); + var auth = new ApiKeyOpcUaUserAuthenticator( + store, + Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "" }), + NullLogger.Instance); + + (await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeFalse(); + } + + // ---------- helpers ---------- + + private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new() + { + NodeAclId = $"ACL-{scope}-{flags}", + ClusterId = "MAIN", + Principal = Principal, + ScopeKind = scope, + ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE", + PermissionFlags = flags, + }; + + private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new( + store, + Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }), + NullLogger.Instance); + + private static (string Presented, FakeApiKeyStore Store) SetUp( + NodePermissions flags = NodePermissions.None, + NodeAclScopeKind scope = NodeAclScopeKind.Cluster) + { + var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper); + var key = new ApiKey + { + ApiKeyId = apiKeyId, + Name = "test key", + SecretHash = hash, + Principal = Principal, + Enabled = true, + CreatedUtc = DateTime.UtcNow, + }; + var grants = flags == NodePermissions.None ? [] : new List { Grant(scope, flags) }; + return (presented, new FakeApiKeyStore(key, grants)); + } + + private sealed class FakeApiKeyStore(ApiKey key, IReadOnlyList grants) : IApiKeyStore + { + public string KeyId => key.ApiKeyId; + + public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) => + Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal) + ? new ApiKeyLookup(key, grants) + : null); + } + + private sealed class ThrowingApiKeyStore : IApiKeyStore + { + public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) => + throw new InvalidOperationException("store is down"); + } +}