Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
8.8 KiB
Design — Shared SQL Server store for inbound API keys (central pair)
Date: 2026-09-02 · Status: Proposed · Register: row 108 (docs/plans/2026-07-08-deferred-work-register.md) · Plan: 2026-09-02-inbound-api-key-shared-store.md
Problem
Inbound API keys (POST /api/{methodName}, Bearer sbk_<keyId>_<secret>) are stored by the shared
ZB.MOM.WW.Auth.ApiKeys library in a per-node SQLite file
(ScadaBridge:InboundApi:ApiKeyStore:SqlitePath, /app/data/inbound-api-keys.sqlite on the rig,
app\data\inbound-api-keys.sqlite on the Windows installs). Key admin runs on whichever central node
serves the management call — through Traefik that is always the active node — so a key exists on
exactly one node. Consequences, all observed or verified on 2026-09-02:
- After an active-central crash the survivor answers 401
KeyNotFoundto every inbound client (docker/verify-delmia-notifier.sh, first run). The rig now needsdocker/sync-inbound-api-keys.shto copy the file by hand before a failover drill can pass. - A key revoked on one node stays valid on the other. Revocation is not cluster-wide.
- The standby-relay path only works because the standby forwards before authenticating.
- Prod
wonder-app-vd03is single-node and unaffected; the QA pairvq03/vq04has the gap today. Component-InboundAPI.md§Storage still says keys live in the configuration database — stale since the 2026-06-02 re-arch (RetireInboundApiKeyStoredropped the SQL table and moved keys to SQLite).
The library ships only a SQLite store (Sqlite/SqliteApiKeyStore, SqliteApiKeyAdminStore,
SqliteApiKeyAuditStore, SqliteAuthStoreMigrator). There is no provider to switch to. This is
not the secrets store: ZB.MOM.WW.Secrets already has a shared SQL Server store for central
(Secrets 0.5.0, ZbSecretsHub), and ${secret:} values are unaffected.
Decision
Move central's inbound key store to the shared SQL Server (the configuration database, own schema
zbauth) by adding a SQL Server provider to the shared library, mirroring what Secrets 0.5.0 did.
SQLite stays the library default and the right answer for single-process consumers
(MxAccessGateway, HistorianGateway, a single-node ScadaBridge). Every reader and writer of a ScadaBridge
central pair then sees one row per key: create, revoke, rotate, scope edits and last_used are
cluster-wide by construction, and there is nothing to replicate or reconcile.
Alternatives rejected:
| Option | Why not |
|---|---|
| Replicate the SQLite file (rig script, robocopy, LocalDb CDC) | Copy-then-restart is operator-driven and still per-node between copies; LocalDb is the site replication engine and central does not host it; a mid-copy revoke is lost. |
| Shared SQLite on a network share | SQLite over SMB/UNC is unsafe under concurrent writers; QA nodes are separate boxes. |
| Central-to-central Akka replication of key rows | New replication mechanism for one table when a shared HA database already exists at central. |
Keys as ${secret:} entries in the secrets hub |
Verification needs last_used, revoke/rotate/scope semantics and an audit log — a key/value secret store is the wrong shape. |
Library change — ZB.MOM.WW.Auth 0.2.1 → 0.3.0 (~/Desktop/scadaproj/ZB.MOM.WW.Auth)
Additive for every existing consumer; the four existing packages bump in lockstep and a fifth is added.
Abstractions— newIApiKeyStoreMigrator { Task MigrateAsync(CancellationToken ct); }(same shape as Secrets'ISecretsStoreMigrator).ApiKeys—SqliteAuthStoreMigrator : IApiKeyStoreMigrator;AddZbApiKeyAuthregistersIApiKeyStoreMigratorwithTryAdd(SQLite) so a provider registered before it displaces every store — the exact mechanismAddZbSecretsSqlServerStoreuses;ApiKeyMigrationHostedServiceandApiKeyAdminCommandsdepend on the interface.ApiKeyAdminCommandskeeps its current ctor (delegating), so MxAccessGateway/HistorianGateway/ScadaBridge compile unchanged at 0.3.0.- New package
ZB.MOM.WW.Auth.ApiKeys.SqlServer(referencesApiKeysforScopeSerializerand the hasher; addsMicrosoft.Data.SqlClient6.0.2, the family-pinned version):SqlServerApiKeyOptions { ConnectionString (required), Schema = "zbauth" }, validated at start.SqlServerAuthSchema— T-SQL for[zbauth].[schema_version],[zbauth].[api_keys],[zbauth].[api_key_audit]and the two indexes (see plan Task 3 for the DDL). Column types map the SQLite schema one-to-one:key_id nvarchar(64) PK,secret_hash varbinary(64)(HMAC-SHA256 = 32 bytes),scopes nvarchar(max)in the existingScopeSerializerformat,*_utc datetimeoffset(7).SqlServerApiKeyStoreMigrator : IApiKeyStoreMigrator— idempotentIF NOT EXISTSDDL with the same retry set Secrets learned live on the central pair's first boot (deadlock 1205, lost CREATE 2714/1913, PK 2627): both central nodes run it at every startup, concurrently.SqlServerApiKeyStore,SqlServerApiKeyAdminStore,SqlServerApiKeyAuditStore— parameterisedSqlCommandports of the SQLite stores, same semantics (FindActiveByKeyId= not revoked;SetEnabled(false)= revoke;Rotatepreserves expiry;Listordered bycreated_utc).SqliteApiKeyStoreImporter.ImportAsync(sqlitePath, ct)— one-shot copy ofapi_keysandapi_key_auditinto SQL Server, insert-where-not-exists onkey_id(audit rows keyed by(key_id, event_type, created_utc)), returns counts. Idempotent, so both nodes of a pair may run it and the result is the union of their files.AddZbApiKeyAuthSqlServer(config, apiKeySectionPath, sqlServerSectionPath)— registers the SQL implementations, then callsAddZbApiKeyAuth.- Tests: contract tests shared with SQLite via a small abstract fixture, run against a live SQL
Server only when
AUTH_SQLSERVER_CONNSTRis set (Skip.IfNot, as Secrets does withSECRETS_SQLSERVER_CONNSTR); the rig'sinfra/MSSQL serves.
ScadaBridge change
- Config (
ScadaBridge:InboundApi:ApiKeyStore): newProvider=Sqlite(default) |SqlServer;SqlServer:ConnectionStringdefaults toScadaBridge:Database:ConfigurationDbwhen empty (no new secret to distribute — the pair already shares that database);SqlServer:Schema=zbauth;ImportFromSqlitePathdefaults to the currentSqlitePathdefault. - Host composition (
Program.cs, central branch):Provider == SqlServer→AddZbApiKeyAuthSqlServer, elseAddZbApiKeyAuthas today.ApiKeyAdminCommandsis composed overIApiKeyStoreMigrator. A new hosted serviceInboundApiKeyStoreImportServiceruns after the library migration: ifProvider == SqlServerand the SQLite file exists, import it and rename it.imported(theSiteLocalDbLegacyMigratorpattern — run once, keep the original). StartupValidator: a central node whose Akka seed list names more than one node and whoseProviderisSqlitelogs a Warning naming this design (availability-first, matching the auto-down decision — not a hard fail, so an un-reconfigured QA still boots).- Docker rig: both centrals get
ScadaBridge__InboundApi__ApiKeyStore__Provider=SqlServer; the connection string falls back to the sharedScadaBridgeConfigdatabase onscadabridge-mssql.docker/sync-inbound-api-keys.shis deleted onceverify-delmia-notifier.shpasses without it. - Windows installs: appsettings are user-held. QA (
vq03/vq04): stop both centrals together, addProvider, start both — the first to boot createszbauthand imports its file, the second imports its own (union). Prodvd03: same change for consistency (single node, import once). If the app login lacks DDL rights, a DBA runsdocs/operations/sql/zbauth-schema.sqlfirst; the migrator finds the version row and skips DDL. Recorded per environment in a deployment record.
Behaviour after the change
| Case | Before | After |
|---|---|---|
| Key created on active, active crashes | survivor: 401 for every caller | survivor verifies against the same rows: 200 |
| Key revoked on one node | still valid on the peer | revoked everywhere immediately |
last_used_utc |
per node | one value, whichever node verified last |
| SQL Server unreachable | inbound auth unaffected (local file) | inbound auth fails closed (401/500) — same blast radius as the config DB today, since method scripts already need it |
Single-node install with Provider=Sqlite |
unchanged | unchanged |
Out of scope
- Site nodes (they host no inbound API). MxAccessGateway/HistorianGateway stay on SQLite.
- Encrypting
secret_hashat rest (it is a peppered HMAC; the pepper stays in config, never in SQL). - Retiring the per-node SQLite code path from the library.