Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
42 KiB
Inbound API Key Shared SQL Server Store — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Give the shared ZB.MOM.WW.Auth.ApiKeys library a SQL Server provider, point both ScadaBridge central nodes at one zbauth schema in the configuration database, import each node's existing SQLite keys once, prove a central failover no longer 401s inbound callers on the docker rig, and retire the rig's file-copy workaround.
Architecture: Two repos, strictly ordered. First ~/Desktop/scadaproj/ZB.MOM.WW.Auth (0.2.1 → 0.3.0): an IApiKeyStoreMigrator abstraction, SQLite implementing it, and a new package ZB.MOM.WW.Auth.ApiKeys.SqlServer whose AddZbApiKeyAuthSqlServer registers SQL Server stores before AddZbApiKeyAuth so the library's TryAdd calls leave them in place (the Secrets 0.5.0 mechanism). Then ScadaBridge pins 0.3.0, adds ApiKeyStore:Provider, composes the SQL provider on central, runs a one-shot SQLite import at boot, and flips the docker rig. Design: docs/plans/2026-09-02-inbound-api-key-shared-store-design.md.
Tech Stack: .NET 10, Microsoft.Data.SqlClient 6.0.2 (family pin), xUnit + Skip.IfNot live gating, Gitea NuGet feed (build/push.sh), docker rig (docker/, MSSQL from infra/).
Facts the implementer must not re-derive (verified 2026-09-02):
- Library layout:
src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.csholds every contract (ApiKeyOptions,IApiKeyStore,IApiKeyAdminStore,IApiKeyAuditStore, records). SQLite lives insrc/ZB.MOM.WW.Auth.ApiKeys/Sqlite/(SqliteAuthSchemav3,SqliteAuthStoreMigrator, three stores,ScopeSerializerpublic,SqliteValueParsinginternal). DI:DependencyInjection/ApiKeyServiceCollectionExtensions.cs+ApiKeyMigrationHostedService(takesSqliteAuthStoreMigratorconcretely).Admin/ApiKeyAdminCommandsctor takesSqliteAuthStoreMigratorconcretely — MxAccessGateway, HistorianGateway and ScadaBridge all construct it that way, so that ctor must survive. - Versioning is lockstep from
Directory.Build.props(<Version>0.2.1</Version>); packages are packed toartifacts/bybuild/pack.shand pushed bybuild/push.sh(GITEA_NUGET_SOURCE,GITEA_NUGET_KEY).README.mdcarries the changelog and still says "current release is 0.1.4" — stale, fix it. - Secrets' SQL Server pattern to copy:
~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/—SecretsSqlServerConnectionFactory,SqlServerSecretsSchema(CREATE SCHEMAviaEXEC,IF NOT EXISTStables),SqlServerSecretsStoreMigrator(retry on 1205/2714/1913/2627,MaxMigrationRetries = 5), and the DI extension's "register beforeAddZbSecrets" comment. Live tests:tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/Live/LiveSqlServerFixture.csgated onSECRETS_SQLSERVER_CONNSTR. - HMAC-SHA256 hashes are 32 bytes (
ApiKeySecretHasher.Hash). Scopes are serialised byScopeSerializer.Serialize/Deserialize— reuse it, do not invent a second format. - ScadaBridge touchpoints:
src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:427-470(store defaults +AddZbApiKeyAuth+ApiKeyAdminCommandscomposition),Directory.Packages.props:101-104(four0.2.1pins),tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs,tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs:753-758andEndpointContentTypeTests.cs:157(constructApiKeyAdminCommandswith the SQLite migrator — keep compiling via the retained ctor). - Rig: both centrals reach
scadabridge-mssqlwithScadaBridge__Database__ConfigurationDb(env indocker/docker-compose.yml, userscadabridge_app, which already created thezbsecretsschema inZbSecretsHubon 2026-08-07, so it holds DDL rights). Local MSSQL for library tests:Server=localhost,1433;User Id=sa;Password=<the infra/ MSSQL_SA_PASSWORD>;TrustServerCertificate=true(infra/docker-compose.yml). - Register row 108 already records the defect; row 107 is the unrelated
DelmiaRecvitem. Do not add a new row — close 108. - The executing session's
docker/sync-inbound-api-keys.shand the preflight indocker/verify-delmia-notifier.shexist because of this defect; Task 14 removes the script and the preflight's dependency on it.
Model assignment: Opus for the library core (schema, migrator race handling, stores, importer) and the ScadaBridge Host composition; Sonnet for the abstraction plumbing, tests that mirror existing ones, config/docs; Opus for the rig verification; Fable (this session) for the final cross-repo review and the publish decision.
Part A — ~/Desktop/scadaproj/ZB.MOM.WW.Auth (library 0.3.0)
All Part A commands run from ~/Desktop/scadaproj/ZB.MOM.WW.Auth. Commit in the scadaproj repo (it is a plain directory of that repo, no nested .git).
Task 1: IApiKeyStoreMigrator abstraction + SQLite implements it
Classification: small Estimated implement time: ~4 min Parallelizable with: none (everything in Part A builds on it) Model: sonnet
Files:
- Modify:
src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs(append) - Modify:
src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs:14 - Modify:
src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs:13-14 - Modify:
src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs:64-68 - Modify:
src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs:36-73 - Test:
tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs
Step 1: Write the failing test (append to ApiKeyServiceCollectionExtensionsTests.cs, following the file's existing container-building helper)
[Fact]
public void AddZbApiKeyAuth_RegistersSqliteMigratorAsIApiKeyStoreMigrator()
{
using ServiceProvider sp = BuildProvider(); // the file's existing helper that calls AddZbApiKeyAuth over a temp SqlitePath
Assert.IsType<SqliteAuthStoreMigrator>(sp.GetRequiredService<IApiKeyStoreMigrator>());
Assert.Same(sp.GetRequiredService<SqliteAuthStoreMigrator>(), sp.GetRequiredService<IApiKeyStoreMigrator>());
}
[Fact]
public void AddZbApiKeyAuth_LeavesPreRegisteredMigratorInPlace()
{
var services = new ServiceCollection();
var fake = new FakeMigrator();
services.AddSingleton<IApiKeyStoreMigrator>(fake);
services.AddZbApiKeyAuth(MinimalConfig(), "ApiKeys"); // the file's existing config helper
using ServiceProvider sp = services.BuildServiceProvider();
Assert.Same(fake, sp.GetRequiredService<IApiKeyStoreMigrator>());
}
private sealed class FakeMigrator : IApiKeyStoreMigrator
{
public Task MigrateAsync(CancellationToken ct) => Task.CompletedTask;
}
Step 2: Run to verify it fails
Run: dotnet test tests/ZB.MOM.WW.Auth.ApiKeys.Tests --filter "FullyQualifiedName~ApiKeyServiceCollectionExtensionsTests" 2>&1 | tail -5
Expected: build error — IApiKeyStoreMigrator does not exist.
Step 3: Implement
ApiKeyContracts.cs (append):
/// <summary>
/// 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.
/// </summary>
public interface IApiKeyStoreMigrator
{
/// <summary>Migrates the store schema.</summary>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that completes when the schema is current.</returns>
Task MigrateAsync(CancellationToken ct);
}
SqliteAuthStoreMigrator.cs: public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStoreMigrator (add using ZB.MOM.WW.Auth.Abstractions.ApiKeys;).
ApiKeyMigrationHostedService.cs: change the primary-ctor parameter type to IApiKeyStoreMigrator migrator.
ApiKeyServiceCollectionExtensions.cs — replace the migrator block:
// 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<AuthSqliteConnectionFactory>()));
services.TryAddSingleton<IApiKeyStoreMigrator>(sp => sp.GetRequiredService<SqliteAuthStoreMigrator>());
ApiKeyAdminCommands.cs: field becomes private readonly IApiKeyStoreMigrator _migrator;; add a new ctor taking IApiKeyStoreMigrator migrator with the same body; the existing ctor taking SqliteAuthStoreMigrator stays and calls this(options, adminStore, auditStore, pepperProvider, (IApiKeyStoreMigrator)migrator, clock). XML-doc both.
Step 4: Run all library tests
Run: dotnet test ZB.MOM.WW.Auth.slnx 2>&1 | tail -5
Expected: Passed!, 0 failed (the two new tests included).
Step 5: Commit (in ~/Desktop/scadaproj)
git add ZB.MOM.WW.Auth/src ZB.MOM.WW.Auth/tests
git commit -m "feat(auth.apikeys): IApiKeyStoreMigrator abstraction — SQLite implements it, DI registers it via TryAdd so a provider registered first wins; ApiKeyAdminCommands accepts the interface (old ctor kept)"
Task 2: New package skeleton ZB.MOM.WW.Auth.ApiKeys.SqlServer + options + connection factory
Classification: small Estimated implement time: ~5 min Parallelizable with: none Model: sonnet
Files:
- Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj - Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs - Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/AuthSqlServerConnectionFactory.cs - Create:
tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj - Create:
tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyOptionsTests.cs - Modify:
ZB.MOM.WW.Auth.slnx(add both projects),Directory.Packages.props(addMicrosoft.Data.SqlClient6.0.2,Xunit.SkippableFactif the Secrets tests use it — check~/Desktop/scadaproj/ZB.MOM.WW.Secrets/Directory.Packages.propsand copy the exact package/version)
Step 1: csproj — copy src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj, then: PackageId ZB.MOM.WW.Auth.ApiKeys.SqlServer; Description "SQL Server API-key store for the ZB.MOM.WW SCADA family — one shared row set for clustered hosts (central pairs)."; ProjectReference to ..\ZB.MOM.WW.Auth.ApiKeys\ZB.MOM.WW.Auth.ApiKeys.csproj (brings Abstractions, ScopeSerializer, hasher); PackageReference Include="Microsoft.Data.SqlClient"; drop Microsoft.Data.Sqlite from this csproj (it is transitive). Same InternalsVisibleTo pattern for the new test project.
Step 2: Failing tests (SqlServerApiKeyOptionsTests.cs)
public sealed class SqlServerApiKeyOptionsTests
{
[Fact]
public void Validate_RejectsMissingConnectionString() =>
Assert.Throws<InvalidOperationException>(() => new SqlServerApiKeyOptions { ConnectionString = " " }.Validate());
[Fact]
public void Validate_RejectsUnsafeSchemaName() =>
Assert.Throws<InvalidOperationException>(() => new SqlServerApiKeyOptions { ConnectionString = "Server=x", Schema = "zb;auth" }.Validate());
[Fact]
public void Schema_DefaultsToZbauth() =>
Assert.Equal("zbauth", new SqlServerApiKeyOptions().Schema);
}
Step 3: Implement
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
/// <summary>Options for the shared SQL Server API-key store (section supplied by the host, e.g. <c>ScadaBridge:InboundApi:ApiKeyStore:SqlServer</c>).</summary>
public sealed class SqlServerApiKeyOptions
{
/// <summary>Full SQL Server connection string. Required. Never a <c>${secret:}</c> reference on hosts whose secret store is itself SQL-backed.</summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>Schema that owns the three tables. Letters, digits and underscore only (it is interpolated into DDL).</summary>
public string Schema { get; set; } = "zbauth";
/// <summary>Fails fast on a blank connection string or an unsafe schema identifier.</summary>
/// <exception cref="InvalidOperationException">The options are not usable.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
throw new InvalidOperationException("SQL Server API-key store requires a ConnectionString.");
if (string.IsNullOrEmpty(Schema) || !Schema.All(c => char.IsAsciiLetterOrDigit(c) || c == '_') || Schema.Length > 64)
throw new InvalidOperationException($"SQL Server API-key store schema name '{Schema}' is not a safe identifier.");
}
}
AuthSqlServerConnectionFactory(SqlServerApiKeyOptions options) mirrors SecretsSqlServerConnectionFactory: Validate() in the ctor, OpenConnectionAsync(ct) returning an opened SqlConnection, and a Schema property.
Step 4: Run dotnet test tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests → 3 passed. dotnet build ZB.MOM.WW.Auth.slnx → succeeded.
Step 5: Commit
git add ZB.MOM.WW.Auth
git commit -m "feat(auth.apikeys.sqlserver): package skeleton, options (schema default zbauth, validated) and connection factory"
Task 3: Schema + migrator (concurrent-boot safe)
Classification: high-risk (DDL run concurrently by both central nodes at every boot) Estimated implement time: ~5 min Parallelizable with: none Model: opus
Files:
- Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs - Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs - Create:
tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs(copy of Secrets' fixture, env varAUTH_SQLSERVER_CONNSTR, creates a throwaway databasezbauth_test_<random>per run and drops it on dispose) - Create:
tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreMigratorLiveTests.cs
Step 1: Failing live tests (all [SkippableFact] + Skip.IfNot(LiveSqlServer.IsEnabled, …) exactly as Secrets does)
Migrate_OnEmptyDatabase_CreatesSchemaTablesAndVersionRow— assertssys.schemashaszbauth, the three tables exist,schema_version.version == SqlServerAuthSchema.CurrentVersion.Migrate_Twice_IsIdempotent.Migrate_Concurrently_FromEightTasks_AllSucceed—Task.WhenAllof 8 migrators on a virgin database; this is the case that bit Secrets live.Migrate_WhenStoredVersionIsNewer_Throws— writeversion = 99, expectAuthStoreMigrationException(reuse the existing exception type fromSqliteAuthStoreMigrator.cs).
Step 2: Run with AUTH_SQLSERVER_CONNSTR="Server=localhost,1433;User Id=sa;Password=<the infra/ MSSQL_SA_PASSWORD>;TrustServerCertificate=true" (start infra/ first: docker compose -f ~/Desktop/ScadaBridge/infra/docker-compose.yml up -d scadabridge-mssql). Expected: compile failure (types missing). Without the variable the tests must report Skipped, not failed.
Step 3: Implement
SqlServerAuthSchema.CurrentVersion = 1. DDL ({schema} interpolated from the validated option only):
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schema}') EXEC(N'CREATE SCHEMA [{schema}]');
IF OBJECT_ID(N'[{schema}].[schema_version]', N'U') IS NULL
CREATE TABLE [{schema}].[schema_version] (
id INT NOT NULL CONSTRAINT PK_{schema}_schema_version PRIMARY KEY CHECK (id = 1),
version INT NOT NULL,
applied_utc DATETIMEOFFSET(7) NOT NULL);
IF OBJECT_ID(N'[{schema}].[api_keys]', N'U') IS NULL
CREATE TABLE [{schema}].[api_keys] (
key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_{schema}_api_keys PRIMARY KEY,
key_prefix NVARCHAR(16) NOT NULL,
secret_hash VARBINARY(64) NOT NULL,
display_name NVARCHAR(256) NOT NULL,
scopes NVARCHAR(MAX) NOT NULL,
constraints NVARCHAR(MAX) NULL,
created_utc DATETIMEOFFSET(7) NOT NULL,
last_used_utc DATETIMEOFFSET(7) NULL,
revoked_utc DATETIMEOFFSET(7) NULL,
expires_utc DATETIMEOFFSET(7) NULL);
IF OBJECT_ID(N'[{schema}].[api_key_audit]', N'U') IS NULL
CREATE TABLE [{schema}].[api_key_audit] (
audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_{schema}_api_key_audit PRIMARY KEY,
key_id NVARCHAR(64) NULL,
event_type NVARCHAR(64) NOT NULL,
remote_address NVARCHAR(64) NULL,
created_utc DATETIMEOFFSET(7) NOT NULL,
details NVARCHAR(MAX) NULL);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'ix_api_keys_revoked_utc' AND object_id = OBJECT_ID(N'[{schema}].[api_keys]'))
CREATE INDEX ix_api_keys_revoked_utc ON [{schema}].[api_keys] (revoked_utc);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'ix_api_key_audit_key_id_created_utc' AND object_id = OBJECT_ID(N'[{schema}].[api_key_audit]'))
CREATE INDEX ix_api_key_audit_key_id_created_utc ON [{schema}].[api_key_audit] (key_id, created_utc);
MERGE [{schema}].[schema_version] AS t USING (SELECT 1 AS id) AS s ON t.id = s.id
WHEN NOT MATCHED THEN INSERT (id, version, applied_utc) VALUES (1, @version, SYSDATETIMEOFFSET());
SqlServerApiKeyStoreMigrator : IApiKeyStoreMigrator — port SqlServerSecretsStoreMigrator including its retry loop and the four error numbers verbatim, its "stored version newer than supported → throw" check, and its MigrateOnceAsync structure (Serializable transaction). Keep the class comment that explains why the retry exists (cite the 2026-08-07 central-pair first boot).
Also ship the same DDL as a standalone file for DBAs: docs/sql/zbauth-schema.sql in the library repo (plain zbauth, @version replaced by the literal), referenced from the README.
Step 4: Run the live tests with the env var → 4 passed; without → 4 skipped. dotnet test ZB.MOM.WW.Auth.slnx still green.
Step 5: Commit
git commit -am "feat(auth.apikeys.sqlserver): zbauth schema v1 + concurrent-boot-safe migrator (retry on 1205/2714/1913/2627, newer-version fail-closed); live tests gated on AUTH_SQLSERVER_CONNSTR"
Task 4: The three SQL Server stores (contract tests shared with SQLite)
Classification: high-risk (auth data path) Estimated implement time: ~5 min per store — split into 4a store, 4b admin store, 4c audit store if the executor's clock says so Parallelizable with: none Model: opus
Files:
- Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStore.cs - Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAdminStore.cs - Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAuditStore.cs - Create:
tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreLiveTests.cs,SqlServerApiKeyAdminStoreLiveTests.cs,SqlServerApiKeyAuditStoreLiveTests.cs
Step 1: Failing tests — port every [Fact] in tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs and SqliteApiKeyAdminStoreTests.cs one-for-one to [SkippableFact]s against the live fixture (same names, same assertions, SampleRecord/AssertRecordEqual helpers copied). Add for the audit store: Append_ThenListRecent_ReturnsNewestFirstWithinLimit. Add one SQL-specific case: FindByKeyId_RoundTripsDateTimeOffsetToTheTick (SQLite stores text; SQL Server must not truncate to milliseconds).
Step 2: Run → compile failure.
Step 3: Implement — parameterised SqlCommands, one connection per operation via the factory, ConfigureAwait(false), ScopeSerializer for scopes, DateTimeOffset bound as SqlDbType.DateTimeOffset. Semantics must match SQLite exactly — read each SQLite method before porting:
FindActiveByKeyIdAsync=revoked_utc IS NULL(expiry is the verifier's job, not the store's).MarkUsedAsync=UPDATE … SET last_used_utc = @when WHERE key_id = @id(no row → no-op).CreateAsync= plainINSERT(a duplicatekey_idthrows, as SQLite does).RevokeAsync/SetEnabledAsync(false)setrevoked_utc = @whenonly when currently NULL and return whether a row changed;SetEnabledAsync(true)clears it.RotateAsyncupdatessecret_hashonly (preservesexpires_utc).DeleteAsyncreturns rows-affected > 0.ListAsyncordered bycreated_utc, key_id.- Audit
ListRecentAsync(limit)=SELECT TOP (@limit) … ORDER BY audit_id DESC.
Step 4: Run live tests → all pass; SQLite suite unchanged.
Step 5: Commit — feat(auth.apikeys.sqlserver): IApiKeyStore/IApiKeyAdminStore/IApiKeyAuditStore over SQL Server, semantics pinned by the SQLite contract tests re-run live
Task 5: One-shot SQLite → SQL Server importer
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 6 Model: opus
Files:
- Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs - Create:
tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs
Step 1: Failing tests
Import_CopiesKeysAndAuditRows_AndReturnsCounts— seed a temp SQLite viaSqliteAuthStoreMigrator+SqliteApiKeyAdminStore(2 keys, one revoked, one with expiry) + 3 audit rows; import; assert SQL ServerListAsyncshows both with identical fields and audit count 3.Import_Twice_IsIdempotent— second run returnsInserted = 0,Skipped = 2.Import_UnionsWithExistingRows— SQL already has key C; SQLite has A and B; result has A, B, C and no row of C was modified (comparelast_used_utc).Import_MissingFile_ReturnsZeroWithoutThrowing.
Step 2: Run → compile failure.
Step 3: Implement
/// <summary>Result of a one-shot import.</summary>
public sealed record ApiKeyImportResult(int KeysInserted, int KeysSkipped, int AuditInserted, int AuditSkipped, bool SourceFound);
/// <summary>
/// Copies every key and audit row from a per-node SQLite store into the shared SQL Server store,
/// insert-where-not-exists (keys by key_id; audit rows by key_id + event_type + created_utc), so it is
/// idempotent and every node of a pair may run it: the result is the UNION of the nodes' files. A row
/// that already exists in SQL Server is never modified — a revoke recorded on one node but not the
/// other therefore survives only if the revoked copy is imported first; operators reconcile by listing.
/// </summary>
public sealed class SqliteApiKeyStoreImporter(AuthSqlServerConnectionFactory sql)
{
public async Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct) { … }
}
Read the SQLite side with a raw SqliteConnection (Mode=ReadOnly) and the column list from SqliteApiKeyStore, parse timestamps exactly like SqliteValueParsing (make that class internal + InternalsVisibleTo the SqlServer project, or duplicate the two-line parser — prefer InternalsVisibleTo). Insert with IF NOT EXISTS (SELECT 1 … WHERE key_id = @id) INSERT … per row inside one transaction.
Step 4: Run → 4 passed live. Step 5: Commit — feat(auth.apikeys.sqlserver): SqliteApiKeyStoreImporter — idempotent insert-where-not-exists copy of keys + audit (union across a pair)
Task 6: DI extension AddZbApiKeyAuthSqlServer + README/changelog + version 0.3.0
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5 Model: sonnet
Files:
- Create:
src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/DependencyInjection/SqlServerApiKeyServiceCollectionExtensions.cs - Test:
tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyServiceCollectionExtensionsTests.cs(no live DB needed — resolves types only) - Modify:
Directory.Build.props(0.2.1→0.3.0),README.md(Packages table row, Consumer Matrix column, "current release" line, new### 0.3.0changelog entry above### 0.1.4)
Step 1: Failing test
[Fact]
public void AddZbApiKeyAuthSqlServer_DisplacesSqliteStoresAndMigrator()
{
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> {
["ApiKeys:TokenPrefix"] = "sbk", ["ApiKeys:PepperSecretName"] = "Pepper", ["Pepper"] = "0123456789abcdef",
["ApiKeys:SqlServer:ConnectionString"] = "Server=unused;TrustServerCertificate=true" }).Build();
var services = new ServiceCollection();
services.AddZbApiKeyAuthSqlServer(cfg, "ApiKeys", "ApiKeys:SqlServer");
using var sp = services.BuildServiceProvider();
Assert.IsType<SqlServerApiKeyStore>(sp.GetRequiredService<IApiKeyStore>());
Assert.IsType<SqlServerApiKeyAdminStore>(sp.GetRequiredService<IApiKeyAdminStore>());
Assert.IsType<SqlServerApiKeyAuditStore>(sp.GetRequiredService<IApiKeyAuditStore>());
Assert.IsType<SqlServerApiKeyStoreMigrator>(sp.GetRequiredService<IApiKeyStoreMigrator>());
Assert.NotNull(sp.GetRequiredService<SqliteApiKeyStoreImporter>());
Assert.NotNull(sp.GetRequiredService<IApiKeyVerifier>());
}
Step 3: Implement
public static IServiceCollection AddZbApiKeyAuthSqlServer(
this IServiceCollection services, IConfiguration config, string apiKeySectionPath, string sqlServerSectionPath)
{
services.Configure<SqlServerApiKeyOptions>(config.GetSection(sqlServerSectionPath));
services.TryAddSingleton(sp => new AuthSqlServerConnectionFactory(sp.GetRequiredService<IOptions<SqlServerApiKeyOptions>>().Value));
// Registered BEFORE AddZbApiKeyAuth so its TryAdd calls find these and leave them alone.
services.TryAddSingleton<IApiKeyStore>(sp => new SqlServerApiKeyStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<IApiKeyAdminStore>(sp => new SqlServerApiKeyAdminStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<IApiKeyAuditStore>(sp => new SqlServerApiKeyAuditStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<SqlServerApiKeyStoreMigrator>();
services.TryAddSingleton<IApiKeyStoreMigrator>(sp => sp.GetRequiredService<SqlServerApiKeyStoreMigrator>());
services.TryAddSingleton<SqliteApiKeyStoreImporter>();
return services.AddZbApiKeyAuth(config, apiKeySectionPath);
}
Note AddZbApiKeyAuth still registers AuthSqliteConnectionFactory from ApiKeyOptions.SqlitePath — harmless (lazy, never opened) and it keeps the importer's source path in one option. Document that in the extension's XML comment.
README changelog entry ### 0.3.0 — SQL Server API-key store (ScadaBridge central pair): the abstraction, the new package, AddZbApiKeyAuthSqlServer, the importer, "SQLite remains default; ApiKeyAdminCommands old ctor retained; consumers on SQLite need no code change to move to 0.3.0", and the docs/sql/zbauth-schema.sql pointer. Fix "current release is 0.1.4" → 0.3.0.
Step 4: Run dotnet test ZB.MOM.WW.Auth.slnx green; bash build/pack.sh produces five 0.3.0 nupkgs in artifacts/.
Step 5: Commit — feat(auth)!: 0.3.0 — ZB.MOM.WW.Auth.ApiKeys.SqlServer package (AddZbApiKeyAuthSqlServer, importer), README/changelog (the ! is for the new package/version, not an API break).
Task 7: Publish 0.3.0 to the Gitea feed + umbrella index
Classification: trivial (but outward-facing — confirm with the user before pushing) Estimated implement time: ~3 min Parallelizable with: none Model: sonnet (the push itself is the user's call)
Files:
- Modify:
~/Desktop/scadaproj/CLAUDE.md(Auth row in the shared-libs table: feed carries0.3.0, newApiKeys.SqlServerpackage, ScadaBridge is its first consumer)
Steps: export GITEA_NUGET_SOURCE=… GITEA_NUGET_KEY=…; bash build/push.sh (needs the user's token — ask, do not guess). Verify: dotnet nuget list source then curl -s "$GITEA_NUGET_SOURCE" | head -c 200 and dotnet package search ZB.MOM.WW.Auth.ApiKeys.SqlServer --source dohertj2-gitea from the ScadaBridge checkout shows 0.3.0. Commit the index change in scadaproj.
Part B — ScadaBridge (~/Desktop/ScadaBridge)
Task 8: Pin 0.3.0 and add ApiKeyStore:Provider options
Classification: small Estimated implement time: ~4 min Parallelizable with: none Model: sonnet
Files:
- Modify:
Directory.Packages.props:101-104(four pins →0.3.0, add<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer" Version="0.3.0" />) - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj(add<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer" />) - Create:
src/ZB.MOM.WW.ScadaBridge.Host/InboundApiKeyStoreOptions.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreOptionsTests.cs
Step 1: Failing tests — Provider_DefaultsToSqlite, Provider_ParsesCaseInsensitively("sqlserver" → SqlServer), Provider_RejectsUnknownValue (validation error names the two legal values).
Step 3: Implement
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>Which backend holds inbound API keys. Bound from <c>ScadaBridge:InboundApi:ApiKeyStore</c> alongside the library's own <c>ApiKeyOptions</c>.</summary>
public enum InboundApiKeyStoreProvider { Sqlite, SqlServer }
/// <summary>Host-side selector for the inbound API key store. <c>Sqlite</c> is per-node (single-node installs); <c>SqlServer</c> shares one row set across a central pair (design 2026-09-02).</summary>
public sealed class InboundApiKeyStoreOptions
{
public const string SectionPath = "ScadaBridge:InboundApi:ApiKeyStore";
public InboundApiKeyStoreProvider Provider { get; set; } = InboundApiKeyStoreProvider.Sqlite;
/// <summary>SQLite file to import once when <see cref="Provider"/> is SqlServer; defaults to the library's <c>SqlitePath</c>. Renamed <c>*.imported</c> afterwards.</summary>
public string? ImportFromSqlitePath { get; set; }
}
Bind with ValidateOnStart (enum binding already rejects unknown names; add a Validate that produces the friendly message).
Step 4: dotnet build ZB.MOM.WW.ScadaBridge.slnx (restores 0.3.0 — Task 7 must have pushed) + the new tests green. Step 5: Commit — chore(host): pin ZB.MOM.WW.Auth 0.3.0 + ApiKeys.SqlServer; InboundApiKeyStoreOptions (Provider Sqlite|SqlServer)
Task 9: Compose the provider in Program.cs + import hosted service
Classification: high-risk (auth composition on central) Estimated implement time: ~5 min Parallelizable with: none Model: opus
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:427-470 - Create:
src/ZB.MOM.WW.ScadaBridge.Host/InboundApiKeyStoreImportService.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs(extend the existing central-composition test: withProvider=SqlServerand a dummy connection string,IApiKeyStoreresolves toSqlServerApiKeyStoreandIApiKeyStoreMigratorto the SQL migrator; with the default, both resolve to the SQLite types) - Test:
tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs(fake importer: file present →ImportAsynccalled once and file renamed.imported; file absent → not called;Provider=Sqlite→ service inert)
Step 3: Implement (Program.cs, replacing lines 447-450 and the ApiKeyAdminCommands migrator argument):
var storeOptions = builder.Configuration.GetSection(InboundApiKeyStoreOptions.SectionPath).Get<InboundApiKeyStoreOptions>() ?? new();
if (storeOptions.Provider == InboundApiKeyStoreProvider.SqlServer)
{
// Shared store for a central pair (design 2026-09-02). The connection string defaults to the
// configuration database — the pair already shares it — under its own `zbauth` schema.
const string sqlSection = apiKeyStoreSection + ":SqlServer";
if (string.IsNullOrWhiteSpace(builder.Configuration[$"{sqlSection}:ConnectionString"]))
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{ [$"{sqlSection}:ConnectionString"] = builder.Configuration["ScadaBridge:Database:ConfigurationDb"] });
builder.Services.AddZbApiKeyAuthSqlServer(builder.Configuration, apiKeyStoreSection, sqlSection);
builder.Services.AddHostedService<InboundApiKeyStoreImportService>();
}
else
{
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
}
…
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator>(), // was SqliteAuthStoreMigrator
InboundApiKeyStoreImportService : IHostedService — registered after AddZbApiKeyAuthSqlServer so it starts after the library's migration hosted service (hosted services start in registration order); resolves SqliteApiKeyStoreImporter, IOptions<InboundApiKeyStoreOptions>, IOptions<ApiKeyOptions> (for the default path), ILogger. StartAsync: path = ImportFromSqlitePath ?? ApiKeyOptions.SqlitePath; if File.Exists → ImportAsync, log Information with the counts, File.Move(path, path + ".imported") (also move -wal/-shm if present). Any exception → log Error and continue (keys already in SQL keep working; a failed import must not take the node down). Keep the ${secret:} caveat from the docker compose comment: the connection string must be a literal or an env override, never a secret reference resolved by the SQL-backed secrets store (circularity).
Step 4: dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests green; dotnet test ZB.MOM.WW.ScadaBridge.slnx green (the InboundAPI and Security tests still construct ApiKeyAdminCommands with the SQLite migrator — the retained ctor keeps them compiling; do not rewrite them).
Step 5: Commit — feat(host): inbound API key store provider — SqlServer shares one zbauth row set across the central pair; one-shot SQLite import at boot (file renamed .imported)
Task 10: StartupValidator warning for a clustered central on the per-node store
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 11, Task 12 Model: sonnet
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs(near the existing inbound-API pepper check at ~line 80) - Test:
tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs
Rule: Central role AND ScadaBridge:Cluster:SeedNodes (whatever key the validator already reads for the self-first check) lists ≥ 2 nodes AND Provider == Sqlite → Warning "Inbound API keys use the per-node SQLite store on a {n}-node central cluster; a failover will reject every inbound client until keys are re-issued. Set ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer (design 2026-09-02)." Not an error (availability-first, like auto-down). Tests: warns for 2 seeds + Sqlite; silent for 1 seed; silent for SqlServer.
Commit — feat(host): StartupValidator warns when a clustered central keeps per-node inbound API keys
Task 11: Docker rig on the shared store; retire the file-copy workaround
Classification: standard Estimated implement time: ~5 min (+ ~3 min rebuild) Parallelizable with: Task 10, Task 12 Model: opus
Files:
- Modify:
docker/docker-compose.yml(both centralenvironment:blocks:ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"with a comment pointing at the design; connection string deliberately NOT set — falls back toScadaBridge__Database__ConfigurationDb) - Modify:
docker-env2/docker-compose.yml(same for its two centrals — that rig is also a pair) - Delete:
docker/sync-inbound-api-keys.sh - Modify:
docker/verify-delmia-notifier.sh(header lines 9-13 + the preflight: keep the "both nodes list the key" check — it is now the proof, not a gate that needs a script — and reword the comment) - Modify:
docker/README.md(remove the sync script line if listed; note the provider)
Steps:
- Edit compose + scripts.
bash docker/deploy.sh(rebuilds the image with 0.3.0 and recreates the pair). - Watch both central logs for the import:
docker logs scadabridge-central-a 2>&1 | grep -i "api-key store import\|zbauth"— expect one node reportingKeysInserted=1(thedelmia-notifierkey) and the otherKeysInserted=0, KeysSkipped=1(both files hold the same key after the earlier sync), andinbound-api-keys.sqlite.importedon both volumes (docker exec scadabridge-central-a ls /app/data). - Confirm one row set:
docker exec scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'ScadaBridge_Dev1#' -d ScadaBridgeConfig -Q "SELECT key_id, display_name, revoked_utc FROM zbauth.api_keys"(adapt the tools path to what the image has). Bothscadabridge security api-key listvia:9001and:9002show the same keys. - Cluster-wide revoke proof:
security api-key create --name revoke-probe --methods DelmiaRecipeDownloadvia:9001;curlthe method through:9002with it → 200;security api-key update --key-id <id> --enabled falsevia:9001; the same curl via:9002→ 401. Thendeleteit. - Run
export DELMIA_API_KEY=…; bash docker/verify-delmia-notifier.shwithout any file copy → A, B, E, FYES; C, GNO. Record the takeover seconds. docker-env2:bash docker-env2/deploy.sh, confirm both of its centrals boot and one imports.
Commit — chore(docker): both rigs' centrals on the shared SQL Server inbound key store; delete sync-inbound-api-keys.sh (superseded); verify script proves both nodes see the key without copying
Task 12: Documentation + register + umbrella index
Classification: trivial (docs only, but wide) Estimated implement time: ~5 min Parallelizable with: Task 10, Task 11 Model: sonnet
Files:
- Modify:
docs/requirements/Component-InboundAPI.md§Storage (lines 23-24: keys live in theZB.MOM.WW.Auth.ApiKeysstore — SQLite per node by default, or the sharedzbauthschema in the configuration database on a central pair viaProvider=SqlServer; revoke/rotate/scope are cluster-wide only under SqlServer) and the Dependencies line 309. - Modify:
docs/operations/inbound-api-key-reissue.md§2 config table (addProvider,SqlServer:ConnectionStringdefault,ImportFromSqlitePath) + a new §"Central pairs" paragraph: stop both together, setProvider, start both, check the import log line on each,security api-key listfrom each node, then delete the.importedfiles after a quarantine. - Create:
docs/operations/sql/zbauth-schema.sql(copy of the library's DDL for DBAs whose app login lacks DDL rights; the migrator then finds the version row and skips). - Modify:
docs/deployment/topology-guide.md(central-pair section: the key store is shared under SqlServer; SQLite on a pair is a documented misconfiguration the validator warns about). - Modify:
env_variables.md(rows forScadaBridge__InboundApi__ApiKeyStore__Provider,…__SqlServer__ConnectionString,…__ImportFromSqlitePath). - Modify:
CLAUDE.md→ Security & Auth: one bullet — inbound key store per node by default; central pairs MUST runProvider=SqlServer(sharedzbauthschema in the config DB, import once at boot, revoke cluster-wide); rig does; QA/prod appsettings are user-held (deployment record). - Modify:
docs/plans/2026-07-08-deferred-work-register.mdrow 108 → resolved (library 0.3.0 + host provider + rig proof; QA/vd03 rollout pending as a deployment record). - Modify:
docs/api-specs/Delmia-Recipe-Download-API.md§8 (the note about the sync script → superseded). - Modify:
../scadaproj/CLAUDE.mdScadaBridge entry (inbound keys on the shared store; Auth 0.3.0 consumer).
Check: grep -rn "sync-inbound-api-keys" docs docker CLAUDE.md → only historical mentions in the register/plan. Commit — docs: inbound API key store — shared SQL Server provider on central pairs (spec, runbook, topology, env inventory, CLAUDE.md, register row 108 closed)
Task 13: Deployment record template for QA (vq03/vq04) and prod (vd03)
Classification: trivial Estimated implement time: ~3 min Parallelizable with: none (after Task 12) Model: sonnet
Files:
- Create:
docs/deployment/deployment-records/2026-09-XX-zb-servers-inbound-key-store.md(a planned record with the exact steps and blanks for results — the user runs it; appsettings on those boxes are user-held)
Content: build/stage per the 2026-08-21 recipe; QA: stop both centrals together → add "ApiKeyStore": { "Provider": "SqlServer" } under ScadaBridge:InboundApi in both appsettings.Central.json → if scadabridge_app lacks DDL rights run docs/operations/sql/zbauth-schema.sql on the QA config DB first → start both → expect one KeysInserted=N line and one KeysSkipped line → security api-key list from 10.220.158.2:8085 and .3:8085 agree → sync-free failover check with a curl against DelmiaRecipeDownload from the non-active node. Prod vd03: single node, same change (KeysInserted=5 expected: mes, mes api key, delmia-mes-receivers, TestKey, JOE_TEST_KEY). Rollback: revert Provider, rename .imported back — no SQL rows need removing (the SQLite path ignores them).
Commit — docs(deployment): planned record for moving QA/prod central inbound key stores to SQL Server
Task 14: Final cross-repo review
Classification: standard Estimated implement time: ~5 min Parallelizable with: none Model: fable (this session)
Check: (1) scadaproj git log shows Tasks 1-7 committed and artifacts/ holds five 0.3.0 packages that match the feed; (2) ScadaBridge git diff main contains no connection string or token (grep -n "Password=\|sbk_[A-Za-z0-9]" <diff> → only the dev-only compose values that were already committed); (3) dotnet test ZB.MOM.WW.ScadaBridge.slnx green; (4) Task 11 step 4 (revoke visible on the peer) and step 5 (drill without copying) are recorded with numbers; (5) MxAccessGateway and HistorianGateway still build against 0.3.0 without edits (dotnet build each after bumping their pins in a scratch branch — do not commit there; report only); (6) the rig's sync-inbound-api-keys.sh is gone and nothing references it. Then ask the user about pushing both repos.
Out of scope
- Site nodes, MxAccessGateway, HistorianGateway (SQLite stays their store).
- Hard-failing a clustered central on
Provider=Sqlite(warning only; revisit after QA is on SqlServer). - Encrypting
secret_hashat rest; the pepper never enters SQL. - Fixing register row 107 (prod
DelmiaRecv) — separate change.