From 3f11704d1077d85f848695b7d18f6b6ec9c51fd3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 2 Sep 2026 14:06:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth.apikeys):=20IApiKeyStoreMigrator=20ab?= =?UTF-8?q?straction=20=E2=80=94=20SQLite=20implements=20it,=20DI=20regist?= =?UTF-8?q?ers=20it=20via=20TryAdd=20so=20a=20provider=20registered=20firs?= =?UTF-8?q?t=20wins;=20ApiKeyAdminCommands=20accepts=20the=20interface=20(?= =?UTF-8?q?old=20ctor=20kept)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB --- .../ApiKeys/ApiKeyContracts.cs | 13 +++++++ .../Admin/ApiKeyAdminCommands.cs | 28 +++++++++++++-- .../ApiKeyMigrationHostedService.cs | 11 +++--- .../ApiKeyServiceCollectionExtensions.cs | 6 ++-- .../Sqlite/SqliteAuthStoreMigrator.cs | 3 +- .../ApiKeyServiceCollectionExtensionsTests.cs | 34 +++++++++++++++++++ 6 files changed, 85 insertions(+), 10 deletions(-) diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs index 765064e..3a2b75e 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs @@ -78,3 +78,16 @@ public interface IApiKeyAuditStore Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct); Task> ListRecentAsync(int limit, CancellationToken ct); } + +/// +/// Creates or upgrades the API-key store schema to the version this build supports. Each store +/// provider (SQLite, SQL Server) supplies one; hosts run it at startup and before admin commands. +/// Implementations must be idempotent and safe to run concurrently from several nodes. +/// +public interface IApiKeyStoreMigrator +{ + /// Migrates the store schema. + /// A token to cancel the operation. + /// A task that completes when the schema is current. + Task MigrateAsync(CancellationToken ct); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs index ac0dff3..4e871bc 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs @@ -33,10 +33,15 @@ public sealed class ApiKeyAdminCommands private readonly IApiKeyAdminStore _adminStore; private readonly IApiKeyAuditStore _auditStore; private readonly IApiKeyPepperProvider _pepperProvider; - private readonly SqliteAuthStoreMigrator _migrator; + private readonly IApiKeyStoreMigrator _migrator; private readonly TimeProvider _clock; - /// Creates the command set over the supplied stores and options. + /// + /// Creates the command set over the supplied stores and options. + /// Retained for existing consumers (MxAccessGateway, HistorianGateway, ScadaBridge) that + /// construct this type against the concrete SQLite migrator; forwards to the + /// overload. + /// /// API-key options (token prefix, store path, ...). /// Mutating store (create / revoke / rotate / delete / list). /// Append-only audit store wired into every mutating verb. @@ -50,6 +55,25 @@ public sealed class ApiKeyAdminCommands IApiKeyPepperProvider pepperProvider, SqliteAuthStoreMigrator migrator, TimeProvider? clock = null) + : this(options, adminStore, auditStore, pepperProvider, (IApiKeyStoreMigrator)migrator, clock) + { + } + + /// Creates the command set over the supplied stores and options. + /// API-key options (token prefix, store path, ...). + /// Mutating store (create / revoke / rotate / delete / list). + /// Append-only audit store wired into every mutating verb. + /// Resolves the pepper used to hash secrets. + /// Schema migrator used by — whichever store + /// provider's implementation (SQLite, SQL Server, ...) DI has registered. + /// Optional clock; defaults to . + public ApiKeyAdminCommands( + ApiKeyOptions options, + IApiKeyAdminStore adminStore, + IApiKeyAuditStore auditStore, + IApiKeyPepperProvider pepperProvider, + IApiKeyStoreMigrator migrator, + TimeProvider? clock = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(adminStore); diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs index a99dd5e..aca8572 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs @@ -1,17 +1,18 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; -using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; /// -/// Runs the API-key SQLite schema migration at application startup when -/// is . -/// The migration is idempotent, so repeated restarts are safe. +/// Runs the registered at application startup when +/// is . The migrator +/// registered in DI is whichever store provider's implementation won the TryAdd race — +/// SQLite by default, or a provider package (e.g. SQL Server) registered before +/// AddZbApiKeyAuth. The migration is idempotent, so repeated restarts are safe. /// internal sealed class ApiKeyMigrationHostedService( - SqliteAuthStoreMigrator migrator, + IApiKeyStoreMigrator migrator, IOptions options) : IHostedService { /// diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs index bd7a62c..092cee1 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs @@ -61,10 +61,12 @@ public static class ApiKeyServiceCollectionExtensions sp.GetRequiredService(), sp.GetRequiredService())); - // Migrator: singleton, constructed from the already-registered connection factory. - // Needed before any store operations so the schema exists. + // Migrator: the SQLite implementation is the default; a provider package that registers + // IApiKeyStoreMigrator (and the three stores) BEFORE this call displaces it — that is the + // whole mechanism by which AddZbApiKeyAuthSqlServer swaps the backend. services.TryAddSingleton(sp => new SqliteAuthStoreMigrator(sp.GetRequiredService())); + services.TryAddSingleton(sp => sp.GetRequiredService()); // Hosted service that runs migrations on startup when ApiKeyOptions.RunMigrationsOnStartup. services.AddHostedService(); diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs index 6439087..78bffa4 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs @@ -1,5 +1,6 @@ using System.Globalization; using Microsoft.Data.Sqlite; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; @@ -11,7 +12,7 @@ public sealed class AuthStoreMigrationException(string message) : InvalidOperati /// is safe to run repeatedly. Refuses to run against a database whose on-disk version /// is newer than this build supports. /// -public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) +public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStoreMigrator { /// Applies the schema migration to the auth store. /// Cancellation token. diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs index 38bf685..684ce5b 100644 --- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; +using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.Tests; @@ -197,4 +198,37 @@ public class ApiKeyServiceCollectionExtensionsTests File.Delete(sqlitePath); } } + + [Fact] + public void AddZbApiKeyAuth_RegistersSqliteMigratorAsIApiKeyStoreMigrator() + { + IConfiguration config = BuildConfiguration(TempSqlitePath()); + var services = new ServiceCollection(); + + services.AddZbApiKeyAuth(config, ApiKeySection); + + using ServiceProvider sp = services.BuildServiceProvider(); + + Assert.IsType(sp.GetRequiredService()); + Assert.Same(sp.GetRequiredService(), sp.GetRequiredService()); + } + + [Fact] + public void AddZbApiKeyAuth_LeavesPreRegisteredMigratorInPlace() + { + var services = new ServiceCollection(); + var fake = new FakeMigrator(); + services.AddSingleton(fake); + + services.AddZbApiKeyAuth(BuildConfiguration(TempSqlitePath()), ApiKeySection); + + using ServiceProvider sp = services.BuildServiceProvider(); + + Assert.Same(fake, sp.GetRequiredService()); + } + + private sealed class FakeMigrator : IApiKeyStoreMigrator + { + public Task MigrateAsync(CancellationToken ct) => Task.CompletedTask; + } }