docs: inbound API key store — shared SQL Server provider on central pairs (spec, runbook + central-pairs procedure, DBA DDL copy, topology, env inventory, CLAUDE.md, register row 108 closed with rig proof, row 109 Secrets backoff follow-up, Delmia spec §8)

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
Joseph Doherty
2026-09-02 23:31:21 -04:00
parent a83cc8613d
commit f47ceb55b1
11 changed files with 1004 additions and 12 deletions
+2 -1
View File
@@ -119,7 +119,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- SMS delivery adapter (T9/T10, 2026-06-19): `SmsNotificationDeliveryAdapter` — Twilio REST, no SDK, HTTP Basic auth (`AccountSid:AuthToken`), one `POST .../Messages.json` per recipient. `SmsConfiguration` entity (`AccountSid`, `AuthToken` encrypted via Data Protection, `FromNumber`/`MessagingServiceSid`, `ApiBaseUrl`, timeout/retry) stored centrally; managed Admin-only via CLI `notification sms list|update` + Central UI `/notifications/sms`. Central-only (never deployed to sites). Per-recipient rollup: all-accepted → Delivered; any-transient → retry/park; mix → delivered-to-good + note; all-permanent → Park. `SmsConfiguration` (Auth Token in SecretsBlock) travels in Transport bundles via `--sms-configs`.
- Inbound API: `POST /api/{methodName}`, `X-API-Key` header, flat JSON, extended type system (Object, List).
- **Inbound API on a STANDBY central node RELAYS to the active peer (2026-08-28)** instead of answering 503 `STANDBY_NODE`. Method scripts/`Route.To()` still run only on the active node (`IActiveNodeGate`); the standby's `InboundApiEndpointFilter` hands the request to `IStandbyRequestForwarder` (`HttpStandbyRequestForwarder`, plain HTTP, verbatim method/path/query/body/headers, peer response replayed unchanged) when `ScadaBridge:InboundApi:StandbyForwardPeerEndpoints` lists the *other* node's HTTP base URL — a static appsettings list, because the Akka member address carries the remoting port, not the HTTP one. Empty list = old 503 behaviour. Peers are tried in order; a peer 503 stamped `X-ScadaBridge-Standby: 1`, a connection failure or a `StandbyForwardTimeout` (60 s, must exceed the longest method timeout) expiry each move to the next, all failing → 503 `ACTIVE_NODE_UNREACHABLE`. **One-hop loop guard:** relayed requests carry `X-ScadaBridge-Forwarded` and are never relayed again. The 413 body cap runs *before* the relay, and the standby's `AuditWriteMiddleware` skips its own row for a relayed call (`InboundApiEndpointFilter.RelayedToActiveItemKey`) so it is audited once, on the node that executed it. Both rigs (`docker/`, `docker-env2/`) configure it on both central nodes; QA/prod appsettings are user-held and need the key added by hand. Spec: `Component-InboundAPI.md` → *Standby forwarding*.
- **DelmiaNotifier rig stand-in + the per-node inbound-key-store trap (2026-09-02).** `docker/seed-delmia-sim.sh` (in-memory `SimMachine` composing `DelmiaReceiverSim` as `DelmiaReceiver`, a `ValueChange` script on the ROOT template playing the Galaxy side) + `seed-delmia-api.sh` (prod's `DelmiaRecipeDownload` method + a scoped key) + `verify-delmia-notifier.sh` (notifier A–G incl. an active-central `docker kill`, takeover measured 27 s, rejoin as standby). The site script is a **static-attribute port** of the prod handshake — `WriteBatchAndWaitAsync` rejects static attributes, so it uses per-attribute `SetAsync` + `WaitAsync`. Two facts the drill established: (1) `docker stop` is a graceful CoordinatedShutdown handover (takeover 0 s) — crash drills must `docker kill`; (2) **inbound API keys live in a PER-NODE SQLite file** (`ScadaBridge:InboundApi:ApiKeyStore:SqlitePath`, each central's own volume), written by whichever node serves the management call, so a key exists on one node only and **a central failover makes every inbound client 401** — the standby-relay path only works because the standby forwards before authenticating. `docker/sync-inbound-api-keys.sh` is the rig-only mirror; the real fix is register row 108. Results: `docs/api-specs/Delmia-Recipe-Download-API.md` §8.
- **DelmiaNotifier rig stand-in + the per-node inbound-key-store trap (2026-09-02).** `docker/seed-delmia-sim.sh` (in-memory `SimMachine` composing `DelmiaReceiverSim` as `DelmiaReceiver`, a `ValueChange` script on the ROOT template playing the Galaxy side) + `seed-delmia-api.sh` (prod's `DelmiaRecipeDownload` method + a scoped key) + `verify-delmia-notifier.sh` (notifier A–G incl. an active-central `docker kill`, takeover measured 27 s, rejoin as standby). The site script is a **static-attribute port** of the prod handshake — `WriteBatchAndWaitAsync` rejects static attributes, so it uses per-attribute `SetAsync` + `WaitAsync`. Two facts the drill established: (1) `docker stop` is a graceful CoordinatedShutdown handover (takeover 0 s) — crash drills must `docker kill`; (2) **inbound API keys live in a PER-NODE SQLite file** (`ScadaBridge:InboundApi:ApiKeyStore:SqlitePath`, each central's own volume), written by whichever node serves the management call, so a key exists on one node only and **a central failover makes every inbound client 401** — the standby-relay path only works because the standby forwards before authenticating — resolved by register row 108 / the shared store; `sync-inbound-api-keys.sh` is deleted. Results: `docs/api-specs/Delmia-Recipe-Download-API.md` §8.
### Templates & Deployment
- Pre-deployment validation includes semantic checks (call targets, argument types, trigger operand types).
@@ -180,6 +180,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- LDAP failure: new logins fail; active sessions continue with current roles.
- Load balancer in front of central UI; cookie-embedded JWT + shared Data Protection keys for failover transparency.
- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.) Pending secured writes expire server-side after a configurable TTL (`ManagementServiceOptions.SecuredWritePendingTtl`, default 24 h): an overdue `Pending` row is CAS'd to `Expired` (never relayed) — enforced at approve/reject and swept opportunistically on list (arch-review S2, `AuditKind.SecuredWriteExpire`).
- **Inbound API key store — per node by default, shared on central pairs (2026-09-02, register row 108).** The shared `ZB.MOM.WW.Auth.ApiKeys` library stores inbound keys in **SQLite per node** unless `ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`, which points both central nodes at the same `zbauth` schema in the configuration database (library `ZB.MOM.WW.Auth.ApiKeys.SqlServer` 0.3.0) — under `SqlServer`, create/revoke/rotate/scope changes are cluster-wide immediately; a node's existing SQLite file is imported once at boot (insert-where-absent, safe on both nodes of a pair) and renamed `*.imported`. Central pairs **MUST** run `Provider=SqlServer` — a clustered Central left on `Sqlite` boots fine but `StartupValidator` logs a Warning, because a failover otherwise 401s every inbound client until keys are re-issued on the survivor. Both docker rigs (`docker/`, `docker-env2/`) run `SqlServer`; QA (`vq03`/`vq04`) and prod (`vd03`) appsettings are user-held and their rollout is a deployment record, not yet applied. Design: `docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`; runbook: `docs/operations/inbound-api-key-reissue.md` → *Central pairs*.
### Cluster & Failover
- **`auto-down` downing strategy (decision 2026-07-21 — availability over partition-safety).** Akka's `AutoDowning` provider, `auto-down-unreachable-after` = 15s: the leader among the REACHABLE members downs the unreachable peer, so a hard crash of EITHER node (active/oldest included) fails over to the survivor in ~25s. Accepted trade: a real partition → dual-active until an operator restarts one side. `keep-oldest` remains a supported `SplitBrainResolverStrategy` value (partition-safe, but an oldest-crash is a total outage — Akka's `down-if-alone` only rescues a side with ≥2 members, proven live + in 1.5.62 source). Decision record: `docs/plans/2026-07-21-auto-down-availability-decision.md`. **Ignore the boot-time Akka warning claiming "keep-majority will be used instead"** — drill-verified benign (2026-08-15, decision-doc postscript): the substitution does not happen with the explicit `AutoDowning` provider-class `BuildHocon` sets. But `AutoDowning` IS deprecated, so **every Akka.NET version bump must re-run `DRILL_MODE=active bash docker/failover-drill.sh`** (register row 70).
+18 -7
View File
@@ -219,7 +219,11 @@ Galaxy. Seeded by CLI only, no DB edits:
configs are not prefixed at flattening, and `ScriptActor` matches `ValueChange` by exact canonical name.
- `docker/seed-delmia-api.sh` — the §4 method verbatim (minus its dead `suf` line), timeout 45 s, plus one key
(`delmia-notifier`) scoped to it; the token is printed once.
- `docker/sync-inbound-api-keys.sh` — **required before any central failover test**, see below.
- ~~`docker/sync-inbound-api-keys.sh` — required before any central failover test, see below.~~
**Superseded 2026-09-02 by register row 108 / the shared SQL Server key store**
(`ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`, now set on both rig centrals): the script
is deleted and `verify-delmia-notifier.sh` needs no file copy before a failover run — both nodes
read the same `zbauth` rows.
- `docker/verify-delmia-notifier.sh` — runs the managed `WWNotifier.dll` from a scratch copy whose
`appsettings.json` carries the key (`ScadaBridge:ApiKey`), with `BaseUrls` listing the two central nodes
directly (not Traefik) so the notifier's own connect-failure failover is what gets exercised.
@@ -254,11 +258,18 @@ README states throughout.
active node. After the first run's takeover, central-b answered **401 `KeyNotFound`** to the key
central-a had minted; its `security api-key list` was `[]`. The standby-relay case (B) passes only
because the standby forwards **before** authenticating. The same asymmetry means a key revoked on one
node stays valid on the other. Rig workaround: `docker/sync-inbound-api-keys.sh` checkpoints central-a's
store on the host (`sqlite3`) and replaces central-b's (target stopped for the copy; both rig nodes share
one pepper, so the hashes verify on either node); the verify script's preflight refuses to start unless
both nodes list the key. The product fix is an architecture decision (shared SQL Server store or file
replication, with cluster-wide revocation) and needs the `ZB.MOM.WW.Auth.ApiKeys` library — deferred-work
register row 108. Prod `wonder-app-vd03` is single-node and unaffected; the QA pair has this gap today.
node stays valid on the other. Rig workaround at the time: `docker/sync-inbound-api-keys.sh`
checkpointed central-a's store on the host (`sqlite3`) and replaced central-b's (target stopped for the
copy; both rig nodes share one pepper, so the hashes verify on either node); the verify script's
preflight refused to start unless both nodes listed the key.
**Superseded 2026-09-02 (same day) by register row 108.** `ZB.MOM.WW.Auth` 0.3.0 adds a SQL Server
provider (`ZB.MOM.WW.Auth.ApiKeys.SqlServer`); both rig centrals now run
`ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`, sharing one `zbauth` row set in the
configuration database, so a key exists on both nodes the instant it's created and a revoke is
cluster-wide immediately. `docker/sync-inbound-api-keys.sh` is deleted; a re-run of this drill needs
no file copy before the failover step. Re-measured 2026-09-03 without the
workaround: A/B/E/F `YES`, C/G `NO`, active-central `docker kill` takeover **28 s** (the earlier run measured 27 s with the file copy), victim rejoined as standby 2 s after restart. Prod `wonder-app-vd03` is single-node and unaffected; the QA pair
(`vq03`/`vq04`) and prod still run `Provider=Sqlite` pending a deployment record.
Deferred-work register row 107 records §7 item 1 (`Children["DelmiaRecv"]`) as the prod follow-up.
+8
View File
@@ -112,6 +112,14 @@ Both central nodes share state through:
- **SQL Server**: All configuration, deployment records, templates, and audit logs.
- **JWT signing key**: Same `JwtSigningKey` in both nodes' configuration.
- **Data Protection keys**: Shared key ring (stored in SQL Server or shared file path).
- **Inbound API keys**: shared **only** when `ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer`
(design 2026-09-02, register row 108) — both nodes then read/write the same `zbauth` schema in the
configuration database, so create/revoke/rotate/scope changes are cluster-wide immediately. The
library default, `Provider = Sqlite`, stores keys in a **per-node** file instead; running `Sqlite`
on a central pair is a documented misconfiguration — `StartupValidator` logs a boot-time Warning
because a failover then 401s every inbound client until keys are re-issued on the survivor. Both
docker rigs run `SqlServer`; see `docs/operations/inbound-api-key-reissue.md` → *Central pairs* to
migrate an existing pair.
### Load Balancer
+30 -1
View File
@@ -40,7 +40,10 @@ environment variables, or your secret store):
| Key | Value | Notes |
|---|---|---|
| `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` | Filesystem path to the SQLite key store | Defaults to `<content-root>/data/inbound-api-keys.sqlite` if unset. Choose a durable, backed-up path on a writable volume. |
| `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` | Filesystem path to the SQLite key store | Defaults to `<content-root>/data/inbound-api-keys.sqlite` if unset. Choose a durable, backed-up path on a writable volume. Used when `Provider` is `Sqlite` (the default) and, when `Provider=SqlServer`, as the source file `ImportFromSqlitePath` imports once at boot. |
| `ScadaBridge:InboundApi:ApiKeyStore:Provider` | `Sqlite` (default) or `SqlServer` | `SqlServer` shares one row set across a central pair (design 2026-09-02, register row 108). Case-insensitive; an unrecognized value fails startup. |
| `ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString` | Defaults to `ScadaBridge:Database:ConfigurationDb` | Only used when `Provider=SqlServer`. Must be a literal or env override, **never** a `${secret:}` reference — the secrets store is itself SQL-backed. |
| `ScadaBridge:InboundApi:ApiKeyStore:ImportFromSqlitePath` | Defaults to `SqlitePath`'s default | Only used when `Provider=SqlServer`. The one-shot import source; renamed `*.imported` after a successful import. |
| `ScadaBridge:InboundApi:ApiKeyPepper` | A strong, random string, **≥ 16 characters** | **DIFFERENT per environment.** Keep it secret (secret store, not source control). This is the HMAC pepper that binds every stored key to this deployment; it is also the verifier's pepper secret. |
Notes:
@@ -52,6 +55,32 @@ Notes:
(`ScadaBridge:InboundApi:ApiKeyStore:RunMigrationsOnStartup = true`); these are
wired by the Host and normally need no operator change.
### Central pairs
A central pair (QA `vq03`/`vq04`, both docker rigs) should run `Provider=SqlServer` — under
the `Sqlite` default a key exists on whichever node served the management call, and a failover
401s every inbound client until keys are re-issued on the survivor (`StartupValidator` warns
about this at boot). To move an existing pair over:
1. Stop **both** central nodes together.
2. Set `ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer` on both nodes.
3. If the app's SQL login lacks CREATE SCHEMA/TABLE rights, have a DBA run
`docs/operations/sql/zbauth-schema.sql` against the configuration database first — the
migrator then finds the version row already present and skips DDL.
4. Start both nodes together.
5. Check the import log line on each node (`grep -i "api-key store import\|zbauth"` in the
node's log) — one node reports the keys it inserted, the other reports the ones it found
already present (skipped); together they cover the union of both nodes' prior SQLite files.
6. Run `security api-key list` against each node's management endpoint and confirm both show
the same keys.
7. After a quarantine period (once you're confident the import is correct and no rollback is
needed), delete the `*.imported` SQLite files (and their `-wal`/`-shm` siblings) from both
nodes' volumes.
**Rollback:** revert `Provider` to `Sqlite` (or remove it) and rename the `*.imported` files
back to their original names on both nodes; no SQL Server rows need to be removed — the SQLite
code path never reads them.
---
## 3. Database migration step
+73
View File
@@ -0,0 +1,73 @@
-- Copy of ZB.MOM.WW.Auth's docs/sql/zbauth-schema.sql as shipped in library version 0.3.0.
-- Original lives at ~/Desktop/scadaproj/ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql — if the two
-- drift, that repo is authoritative; re-copy it here rather than editing this file independently.
-- ZB.MOM.WW.Auth.ApiKeys.SqlServer — shared API-key schema, version 1.
--
-- For DBAs whose application login lacks DDL rights. The host normally provisions this itself at
-- startup (SqlServerApiKeyStoreMigrator, which runs the identical statements and is safe to run
-- concurrently from every node); where the app login may only read and write rows, run this script
-- once against the target database instead. The migrator then finds the version row already at the
-- version it supports and does nothing further beyond re-stamping applied_utc.
--
-- The schema name is hard-coded to zbauth here — the default of SqlServerApiKeyOptions.Schema. If a
-- deployment overrides that option, substitute the name in every bracketed identifier below.
--
-- Kept byte-for-byte consistent with SqlServerAuthSchema.CreateSchemaDdl apart from that
-- substitution and the literal version row at the end; change both together.
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'zbauth')
EXEC(N'CREATE SCHEMA [zbauth]');
IF OBJECT_ID(N'[zbauth].[schema_version]', N'U') IS NULL
CREATE TABLE [zbauth].[schema_version] (
id INT NOT NULL CONSTRAINT PK_zbauth_schema_version PRIMARY KEY CHECK (id = 1),
version INT NOT NULL,
applied_utc DATETIMEOFFSET(7) NOT NULL
);
IF OBJECT_ID(N'[zbauth].[api_keys]', N'U') IS NULL
CREATE TABLE [zbauth].[api_keys] (
key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_zbauth_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'[zbauth].[api_key_audit]', N'U') IS NULL
CREATE TABLE [zbauth].[api_key_audit] (
audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_zbauth_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'[zbauth].[api_keys]'))
CREATE INDEX ix_api_keys_revoked_utc
ON [zbauth].[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'[zbauth].[api_key_audit]'))
CREATE INDEX ix_api_key_audit_key_id_created_utc
ON [zbauth].[api_key_audit] (key_id, created_utc);
MERGE [zbauth].[schema_version] AS target
USING (SELECT 1 AS id, 1 AS version, SYSDATETIMEOFFSET() AS applied_utc) AS source
ON target.id = source.id
WHEN MATCHED THEN
UPDATE SET version = source.version, applied_utc = source.applied_utc
WHEN NOT MATCHED THEN
INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc);
@@ -100,7 +100,8 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
| 105 | ~~**Design constraint (not a defect): a LocalDb-replicated table must never be the parent of a row-mutating `ON DELETE` foreign key (`CASCADE`/`SET NULL`/`SET DEFAULT`) from another table**~~ **PINNED 2026-08-20.** Enforcement moved from the first replicated boot to the build: `SiteLocalDbReplicatedTableFkGuardTests` (Host.Tests) builds the real site schema via `OnReady` on an unreplicated config (so 0.3.3's own guard can't mask which FK is at fault), walks `SiteLocalDbSetup.ReplicatedTables` (now `internal`), asserts each exists and has no inbound row-mutating `ON DELETE` FK with the same `sqlite_master JOIN pragma_foreign_key_list` query LocalDb uses, and carries a negative control proving the checker finds a cascading child. The constraint itself stands — the row is kept as its record. Original: | Arch-review 2026-08-19 P4-7 → LocalDb 0.3.3 (scadaproj `122940c` + review follow-up `9663279`, Low-wave T5) | `LwwApplier` upserts with `INSERT OR REPLACE` under `foreign_keys=ON`, so the implicit delete fires the child action while CDC capture is suppressed — the child rows change on the applying node only and the pair diverges silently. 0.3.3 makes `RegisterReplicated` refuse such a parent at registration (clear exception naming the child FK), which is a BOOT failure for the site node, so this is enforced, not advisory. None of the ten `SiteLocalDbSetup.ReplicatedTables` has any FK today (zero `REFERENCES` on the live rig file). Model cross-table cleanup as explicit application deletes on each table (as the 3-table deploy cascade already does), never as SQLite FK actions. | Whenever a schema change adds a foreign key between site tables, or an eleventh table joins `ReplicatedTables`. |
| 106 | ~~**LocalDb: a converged pair re-streams a FULL snapshot on EVERY reconnect once the sender's oplog has been ack-pruned to empty and the receiver's watermark is 0**~~ **RESOLVED 2026-08-20 — LocalDb 0.3.4 (scadaproj `75c1897`), published and pinned.** `SnapshotStreamer.SendAsync` now stamps `as_of_seq = max(MAX(seq), last_acked_seq)`; pinned by `WipedPeer_SnapshotLiftsItsWatermark_SoTheNextReconnectSendsNoSecondSnapshot` (fails on 0.3.3 exactly as the rig did: watermark 0, expected 3). Live proof on the rig: the first restart on 0.3.4 sent one last full snapshot stamped `as_of_seq 149` (was 0) and node-a's `last_applied_remote_seq` went 0 → 149; a second restart of the pair exchanged **zero** snapshots, `Connected=true`, backlog 0. Original: | Found 2026-08-20 while verifying row 104 on the rig (site-a-a received the same 127 k-row full snapshot at both the 13:09 deploy restart and the 13:25 VACUUM restart) | Sender-side defect in `SnapshotStreamer.SendAsync`: `as_of_seq = MAX(seq)` of the oplog, which is **0** once the pair has converged and ack-pruning emptied it. The receiver's `SetLastAppliedRemoteSeqAsync` is MAX-guarded (no regression — good), but a peer whose watermark is genuinely 0 (site-a-a was rebuilt after the 2026-08-15 corruption; any wiped/re-imaged node is the same) can never be lifted by an `as_of_seq 0` snapshot, so the 0.1.2 empty-oplog horizon rule (`streamableFrom = last_acked_seq + 1`) sees `0 < 149` as a gap on every handshake and re-streams everything. Rig state: node-b `last_acked_seq=149`, oplog empty; node-a `last_applied_remote_seq=0`. Not a divergence (LWW absorbs the re-delivered rows) but a cost/availability bug — ~1 s and 127 k rows here, minutes and a full-file read on a 1 GB production pair, on every reconnect, until the sender happens to write a new row. Fix is sender-side and wire-compatible: stamp `as_of_seq = max(MAX(seq), last_acked_seq)` (a full snapshot taken now covers every delta ever pruned below `last_acked_seq`), so the receiver's MAX-guarded watermark lifts to 149 and the loop ends; add a pinning test (converged+pruned sender, zero-watermark receiver → exactly one snapshot across two handshakes). Ships as LocalDb 0.3.4. | Next LocalDb release (0.3.4) — small, well-understood, and worth doing before any production pair is rebuilt from its peer. |
| 107 | **Prod `CvdReactor.ProcessRecipeDownload` still addresses `Children["DelmiaRecv"]` after the 2026-06-27 `DelmiaRecv` → `DelmiaReceiver` composition rename** — inferred from the deployed script (`docs/api-specs/Delmia-Recipe-Download-API.md` §7 item 1): `ChildrenAccessor` concatenates the name into a path, an unknown path yields null, so every call answers `Receiver ready flag not set to true` regardless of the real flag. No call has hit prod since the rename, so this is inferred, not observed. The docker rig's stand-in (`docker/seed-delmia-sim.sh`) uses `Children["DelmiaReceiver"]` and the same handshake answers correctly, which is the working reference. | Found 2026-09-02 while capturing the as-deployed spec; QA copy unread (credentials) | Separate change with its own deployment record: `scadabridge template script update` on the `CvdReactor` template + redeploy Z28061/Z28062/Z28061Sim, then a live `WWNotifier` call. | Before the first production Delmia recipe download. |
| 108 | **Inbound API keys are a PER-NODE SQLite store — a central failover invalidates every inbound client's key** *(High, availability + security-adjacent)*. `ZB.MOM.WW.Auth.ApiKeys` keeps keys in `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` (`/app/data/inbound-api-keys.sqlite`, on each central node's OWN data volume), and key admin (`ManagementActor`, a plain per-node `ActorOf` reached in-process from that node's `/management` endpoints) writes only the node that serves the call — via Traefik that is always the active node. So a key exists on exactly one node: after an active-central crash the survivor answers **401 `KeyNotFound`** to every inbound call (found live 2026-09-02 by `docker/verify-delmia-notifier.sh`: the standby-relay case passes only because the standby forwards BEFORE authenticating). The same asymmetry means a key revoked on one node stays valid on the other. Rig workaround: `docker/sync-inbound-api-keys.sh` mirrors the file (both nodes share one pepper); the verify script's preflight refuses to run unless both nodes list the key. Prod `wonder-app-vd03` is single-node; the QA pair (`vq03`/`vq04`) has this gap today. | `docs/api-specs/Delmia-Recipe-Download-API.md` §8; `docker/sync-inbound-api-keys.sh` header | Fix is an architecture decision, not a patch: move the store to the shared SQL Server (as Secrets 0.5.0 did for its central store) or replicate the file; either way revocation must become cluster-wide. Needs the `ZB.MOM.WW.Auth.ApiKeys` library (scadaproj), not just this repo. | Before any 2-node central goes live with inbound API clients; before the next arch-review wave. |
| 108 | ~~**Inbound API keys are a PER-NODE SQLite store — a central failover invalidates every inbound client's key**~~ **RESOLVED 2026-09-02 — `ZB.MOM.WW.Auth` 0.3.0 (new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package) + ScadaBridge `ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`.** A central pair now shares one row set: both nodes' key stores, admin stores, and audit stores point at the same `zbauth` schema in the configuration database (connection string defaults to `ScadaBridge:Database:ConfigurationDb` — never a `${secret:}` reference, to avoid a circular dependency on the SQL-backed secrets store), so create/revoke/rotate/scope changes are cluster-wide immediately instead of per-node. Each node's existing SQLite file is imported once at boot (insert-where-absent, so both nodes importing is safe and the result is the union) and renamed `*.imported`. `Provider` defaults to `Sqlite` (unchanged behavior for single-node installs, MxAccessGateway, HistorianGateway); a clustered Central left on `Sqlite` now gets a boot-time `StartupValidator` Warning naming this design instead of silently keeping the gap. Both docker rigs (`docker/`, `docker-env2/`) run `Provider=SqlServer` on both centrals; `docker/sync-inbound-api-keys.sh` is deleted, superseded by the shared store. Rig proof 2026-09-03 (`docker/`, both centrals redeployed on `Provider=SqlServer`): both nodes imported their SQLite file at the same second with no PK race — central-a `1 key / 264 audit rows inserted`, central-b `0 / 26 inserted, 1 / 264 already present` (the union), files renamed `*.imported`; a key minted via `:9001` answered `200` via `:9002`, `--enabled false` via `:9001` made both nodes answer `401` and `:9002`'s own list showed `enabled=False`, re-enable via `:9002` restored `200` on `:9001`, delete via `:9002` made `:9001` answer `401`; `verify-delmia-notifier.sh` with a freshly minted key and NO file copy: A/B/E/F `YES`, C/G `NO`, active-central `docker kill` takeover **28 s**, rejoin as standby in 2 s. `docker-env2` (second pair, `ScadaBridgeConfig2`): both centrals booted, `zbauth` v1 created, empty files imported. QA (`vq03`/`vq04`) and prod (`vd03`) still run `Provider=Sqlite` — their move is a planned deployment record (`docs/deployment/deployment-records/2026-09-XX-zb-servers-inbound-key-store.md`), not yet applied. Design: `docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`. Original: | `docs/api-specs/Delmia-Recipe-Download-API.md` §8; `docker/sync-inbound-api-keys.sh` header | Fix is an architecture decision, not a patch: move the store to the shared SQL Server (as Secrets 0.5.0 did for its central store) or replicate the file; either way revocation must become cluster-wide. Needs the `ZB.MOM.WW.Auth.ApiKeys` library (scadaproj), not just this repo. | Before any 2-node central goes live with inbound API clients; before the next arch-review wave. |
| 109 | **`ZB.MOM.WW.Secrets.Replicator.SqlServer`'s store migrator retries its DDL with a flat `100 * attempt` backoff and no jitter — a latent concurrent-boot flake, open in Secrets** *(Low)*. Porting that migrator's retry loop verbatim for the Auth 0.3.0 SQL Server API-key store (register row 108) reproduced an intermittent exhaustion — about once in sixty 8-way concurrent-boot trials the five flat-backoff retries ran out before a DDL/lock conflict cleared; adding jitter to the Auth port's backoff took it to 0 failures in 180 trials. Secrets' own migrator has no concurrent-migrate test, so the same defect is presumed still open there — it just hasn't been exercised at 8-way concurrency, and a ScadaBridge central pair is only ever 2-way, so this is unlikely to bite in practice. | Found 2026-09-02 while implementing `docs/plans/2026-09-02-inbound-api-key-shared-store.md` (Auth 0.3.0 `SqlServerApiKeyStoreMigrator`) | Add jitter to `SqlServerSecretsStoreMigrator`'s backoff in `~/Desktop/scadaproj/ZB.MOM.WW.Secrets` (mirror the Auth fix) and add a `Migrate_Concurrently_FromEightTasks_AllSucceed`-shaped live test to catch a regression. Lives entirely in scadaproj, not this repo. | Next time Secrets' SQL Server migrator is touched, or if a wider-than-2-way concurrent boot of a Secrets-backed cluster is ever planned. |
| 68 | ~~**`deploy.sh` run from a git worktree silently rehomes every node's bind-mount data dir**~~ **RESOLVED 2026-08-15 (guard added in the same close-out).** The compose files' bind mounts are relative (`./site-a-node-a/data`), so a deploy from a worktree checkout mounts the worktree's copies — the rig keeps running and looks healthy, but its SQLite stores split from the main checkout's and everything written there is orphaned by the next deploy from main. Bit the rows-46–53 close-out live: the seeding agent's `rig-probe-1` deploy persisted into worktree-homed dirs and "vanished" after the orchestrator's redeploy (no tombstone, no product bug — LWW/tombstone forensics ruled out deletion; the row sat in `.claude/worktrees/…/docker/site-a-node-a/data/site-localdb.db` the whole time). Both `docker/deploy.sh` and `docker-env2/deploy.sh` now refuse to run from a `.claude/worktrees/` path (override: `SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1`). | Found during the rows-46–53 close-out | Closed 2026-08-15. | — |
## Resolved (verified against the code 2026-07-10)
@@ -0,0 +1,118 @@
# 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 `KeyNotFound`** to every inbound client
(`docker/verify-delmia-notifier.sh`, first run). The rig now needs `docker/sync-inbound-api-keys.sh`
to 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-vd03` is single-node and unaffected; the QA pair `vq03`/`vq04` has the gap today.
- `Component-InboundAPI.md` §Storage still says keys live in the configuration database — stale since
the 2026-06-02 re-arch (`RetireInboundApiKeyStore` dropped 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.
1. **`Abstractions`** — new `IApiKeyStoreMigrator { Task MigrateAsync(CancellationToken ct); }`
(same shape as Secrets' `ISecretsStoreMigrator`).
2. **`ApiKeys`** — `SqliteAuthStoreMigrator : IApiKeyStoreMigrator`; `AddZbApiKeyAuth` registers
`IApiKeyStoreMigrator` with `TryAdd` (SQLite) so a provider registered *before* it displaces every
store — the exact mechanism `AddZbSecretsSqlServerStore` uses; `ApiKeyMigrationHostedService` and
`ApiKeyAdminCommands` depend on the interface. `ApiKeyAdminCommands` **keeps** its current ctor
(delegating), so MxAccessGateway/HistorianGateway/ScadaBridge compile unchanged at 0.3.0.
3. **New package `ZB.MOM.WW.Auth.ApiKeys.SqlServer`** (references `ApiKeys` for `ScopeSerializer`
and the hasher; adds `Microsoft.Data.SqlClient` 6.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 existing `ScopeSerializer` format,
`*_utc datetimeoffset(7)`.
- `SqlServerApiKeyStoreMigrator : IApiKeyStoreMigrator` — idempotent `IF NOT EXISTS` DDL 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` — parameterised
`SqlCommand` ports of the SQLite stores, same semantics (`FindActiveByKeyId` = not revoked;
`SetEnabled(false)` = revoke; `Rotate` preserves expiry; `List` ordered by `created_utc`).
- `SqliteApiKeyStoreImporter.ImportAsync(sqlitePath, ct)` — one-shot copy of `api_keys` and
`api_key_audit` into SQL Server, **insert-where-not-exists on `key_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 calls `AddZbApiKeyAuth`.
- Tests: contract tests shared with SQLite via a small abstract fixture, run against a live SQL
Server only when `AUTH_SQLSERVER_CONNSTR` is set (`Skip.IfNot`, as Secrets does with
`SECRETS_SQLSERVER_CONNSTR`); the rig's `infra/` MSSQL serves.
## ScadaBridge change
- **Config** (`ScadaBridge:InboundApi:ApiKeyStore`): new `Provider` = `Sqlite` (default) | `SqlServer`;
`SqlServer:ConnectionString` **defaults to `ScadaBridge:Database:ConfigurationDb`** when empty (no
new secret to distribute — the pair already shares that database); `SqlServer:Schema` = `zbauth`;
`ImportFromSqlitePath` defaults to the current `SqlitePath` default.
- **Host composition** (`Program.cs`, central branch): `Provider == SqlServer` →
`AddZbApiKeyAuthSqlServer`, else `AddZbApiKeyAuth` as today. `ApiKeyAdminCommands` is composed over
`IApiKeyStoreMigrator`. A new hosted service `InboundApiKeyStoreImportService` runs **after** the
library migration: if `Provider == SqlServer` and the SQLite file exists, import it and rename it
`.imported` (the `SiteLocalDbLegacyMigrator` pattern — run once, keep the original).
- **`StartupValidator`**: a central node whose Akka seed list names more than one node and whose
`Provider` is `Sqlite` logs 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 shared `ScadaBridgeConfig` database on `scadabridge-mssql`.
`docker/sync-inbound-api-keys.sh` is **deleted** once `verify-delmia-notifier.sh` passes without it.
- **Windows installs**: appsettings are user-held. QA (`vq03`/`vq04`): stop both centrals **together**,
add `Provider`, start both — the first to boot creates `zbauth` and imports its file, the second
imports its own (union). Prod `vd03`: same change for consistency (single node, import once). If the
app login lacks DDL rights, a DBA runs `docs/operations/sql/zbauth-schema.sql` first; 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_hash` at rest (it is a peppered HMAC; the pepper stays in config, never in SQL).
- Retiring the per-node SQLite code path from the library.
@@ -0,0 +1,591 @@
# 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.cs` holds every contract (`ApiKeyOptions`, `IApiKeyStore`, `IApiKeyAdminStore`, `IApiKeyAuditStore`, records). SQLite lives in `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/` (`SqliteAuthSchema` v3, `SqliteAuthStoreMigrator`, three stores, `ScopeSerializer` **public**, `SqliteValueParsing` internal). DI: `DependencyInjection/ApiKeyServiceCollectionExtensions.cs` + `ApiKeyMigrationHostedService` (takes `SqliteAuthStoreMigrator` concretely). `Admin/ApiKeyAdminCommands` ctor takes `SqliteAuthStoreMigrator` concretely — 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 to `artifacts/` by `build/pack.sh` and pushed by `build/push.sh` (`GITEA_NUGET_SOURCE`, `GITEA_NUGET_KEY`). `README.md` carries 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 SCHEMA` via `EXEC`, `IF NOT EXISTS` tables), `SqlServerSecretsStoreMigrator` (retry on 1205/2714/1913/2627, `MaxMigrationRetries = 5`), and the DI extension's "register before `AddZbSecrets`" comment. Live tests: `tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/Live/LiveSqlServerFixture.cs` gated on `SECRETS_SQLSERVER_CONNSTR`.
- HMAC-SHA256 hashes are 32 bytes (`ApiKeySecretHasher.Hash`). Scopes are serialised by `ScopeSerializer.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` + `ApiKeyAdminCommands` composition), `Directory.Packages.props:101-104` (four `0.2.1` pins), `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs:753-758` and `EndpointContentTypeTests.cs:157` (construct `ApiKeyAdminCommands` with the SQLite migrator — keep compiling via the retained ctor).
- Rig: both centrals reach `scadabridge-mssql` with `ScadaBridge__Database__ConfigurationDb` (env in `docker/docker-compose.yml`, user `scadabridge_app`, which already created the `zbsecrets` schema in `ZbSecretsHub` on 2026-08-07, so it holds DDL rights). Local MSSQL for library tests: `Server=localhost,1433;User Id=sa;Password=ScadaBridge_Dev1#;TrustServerCertificate=true` (`infra/docker-compose.yml`).
- Register row 108 already records the defect; row 107 is the unrelated `DelmiaRecv` item. Do not add a new row — close 108.
- The executing session's `docker/sync-inbound-api-keys.sh` and the preflight in `docker/verify-delmia-notifier.sh` exist 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)
```csharp
[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):
```csharp
/// <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:
```csharp
// 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`)
```bash
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` (add `Microsoft.Data.SqlClient` **6.0.2**, `Xunit.SkippableFact` if the Secrets tests use it — check `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/Directory.Packages.props` and 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`)
```csharp
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**
```csharp
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**
```bash
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 var **`AUTH_SQLSERVER_CONNSTR`**, creates a throwaway database `zbauth_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` — asserts `sys.schemas` has `zbauth`, the three tables exist, `schema_version.version == SqlServerAuthSchema.CurrentVersion`.
- `Migrate_Twice_IsIdempotent`.
- `Migrate_Concurrently_FromEightTasks_AllSucceed` — `Task.WhenAll` of 8 migrators on a virgin database; this is the case that bit Secrets live.
- `Migrate_WhenStoredVersionIsNewer_Throws` — write `version = 99`, expect `AuthStoreMigrationException` (reuse the existing exception type from `SqliteAuthStoreMigrator.cs`).
**Step 2: Run** with `AUTH_SQLSERVER_CONNSTR="Server=localhost,1433;User Id=sa;Password=ScadaBridge_Dev1#;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):
```sql
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**
```bash
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 `SqlCommand`s, 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` = plain `INSERT` (a duplicate `key_id` throws, as SQLite does).
- `RevokeAsync`/`SetEnabledAsync(false)` set `revoked_utc = @when` only when currently NULL and return whether a row changed; `SetEnabledAsync(true)` clears it. `RotateAsync` updates `secret_hash` only (preserves `expires_utc`). `DeleteAsync` returns rows-affected > 0. `ListAsync` ordered by `created_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 via `SqliteAuthStoreMigrator` + `SqliteApiKeyAdminStore` (2 keys, one revoked, one with expiry) + 3 audit rows; import; assert SQL Server `ListAsync` shows both with identical fields and audit count 3.
- `Import_Twice_IsIdempotent` — second run returns `Inserted = 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 (compare `last_used_utc`).
- `Import_MissingFile_ReturnsZeroWithoutThrowing`.
**Step 2: Run** → compile failure.
**Step 3: Implement**
```csharp
/// <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.0` changelog entry above `### 0.1.4`)
**Step 1: Failing test**
```csharp
[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**
```csharp
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 carries `0.3.0`, new `ApiKeys.SqlServer` package, 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**
```csharp
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: with `Provider=SqlServer` and a dummy connection string, `IApiKeyStore` resolves to `SqlServerApiKeyStore` and `IApiKeyStoreMigrator` to 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 → `ImportAsync` called 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):
```csharp
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 central `environment:` blocks: `ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"` with a comment pointing at the design; connection string deliberately NOT set — falls back to `ScadaBridge__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:**
1. Edit compose + scripts. `bash docker/deploy.sh` (rebuilds the image with 0.3.0 and recreates the pair).
2. Watch both central logs for the import: `docker logs scadabridge-central-a 2>&1 | grep -i "api-key store import\|zbauth"` — expect one node reporting `KeysInserted=1` (the `delmia-notifier` key) and the other `KeysInserted=0, KeysSkipped=1` (both files hold the same key after the earlier sync), and `inbound-api-keys.sqlite.imported` on both volumes (`docker exec scadabridge-central-a ls /app/data`).
3. 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). Both `scadabridge security api-key list` via `:9001` and `:9002` show the same keys.
4. **Cluster-wide revoke proof:** `security api-key create --name revoke-probe --methods DelmiaRecipeDownload` via `:9001`; `curl` the method through `:9002` with it → 200; `security api-key update --key-id <id> --enabled false` via `:9001`; the same curl via `:9002` → **401**. Then `delete` it.
5. Run `export DELMIA_API_KEY=…; bash docker/verify-delmia-notifier.sh` **without any file copy** → A, B, E, F `YES`; C, G `NO`. Record the takeover seconds.
6. `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 the `ZB.MOM.WW.Auth.ApiKeys` store — SQLite per node by default, or the shared `zbauth` schema in the configuration database on a central pair via `Provider=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 (add `Provider`, `SqlServer:ConnectionString` default, `ImportFromSqlitePath`) + a new §"Central pairs" paragraph: stop both together, set `Provider`, start both, check the import log line on each, `security api-key list` from each node, then delete the `.imported` files 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 for `ScadaBridge__InboundApi__ApiKeyStore__Provider`, `…__SqlServer__ConnectionString`, `…__ImportFromSqlitePath`).
- Modify: `CLAUDE.md` → *Security & Auth*: one bullet — inbound key store per node by default; central pairs MUST run `Provider=SqlServer` (shared `zbauth` schema 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.md` row 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.md` ScadaBridge 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_hash` at rest; the pepper never enters SQL.
- Fixing register row 107 (prod `DelmiaRecv`) — separate change.
@@ -0,0 +1,133 @@
{
"planPath": "docs/plans/2026-09-02-inbound-api-key-shared-store.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: IApiKeyStoreMigrator abstraction + SQLite implements it (scadaproj)",
"status": "completed",
"model": "sonnet"
},
{
"id": 2,
"subject": "Task 2: ZB.MOM.WW.Auth.ApiKeys.SqlServer skeleton + options + connection factory",
"status": "completed",
"blockedBy": [
1
],
"model": "sonnet"
},
{
"id": 3,
"subject": "Task 3: zbauth schema + concurrent-boot-safe migrator",
"status": "completed",
"blockedBy": [
2
],
"model": "opus"
},
{
"id": 4,
"subject": "Task 4: SqlServerApiKeyStore / AdminStore / AuditStore (contract tests live)",
"status": "completed",
"blockedBy": [
3
],
"model": "opus"
},
{
"id": 5,
"subject": "Task 5: SqliteApiKeyStoreImporter (idempotent union)",
"status": "completed",
"blockedBy": [
4
],
"model": "opus"
},
{
"id": 6,
"subject": "Task 6: AddZbApiKeyAuthSqlServer DI + README/changelog + version 0.3.0",
"status": "completed",
"blockedBy": [
4
],
"model": "sonnet"
},
{
"id": 7,
"subject": "Task 7: Publish 0.3.0 to the Gitea feed + umbrella index (user confirms push)",
"status": "completed",
"blockedBy": [
5,
6
],
"model": "sonnet"
},
{
"id": 8,
"subject": "Task 8: ScadaBridge pins 0.3.0 + InboundApiKeyStoreOptions (Provider)",
"status": "completed",
"blockedBy": [
7
],
"model": "sonnet"
},
{
"id": 9,
"subject": "Task 9: Program.cs provider composition + InboundApiKeyStoreImportService",
"status": "completed",
"blockedBy": [
8
],
"model": "opus"
},
{
"id": 10,
"subject": "Task 10: StartupValidator warning for clustered central on SQLite keys",
"status": "completed",
"blockedBy": [
9
],
"model": "sonnet"
},
{
"id": 11,
"subject": "Task 11: Docker rigs on the shared store; delete sync-inbound-api-keys.sh; drill without copying",
"status": "in_progress",
"blockedBy": [
9
],
"model": "opus"
},
{
"id": 12,
"subject": "Task 12: Docs, runbook, DBA SQL, env inventory, CLAUDE.md, register row 108, umbrella index",
"status": "in_progress",
"blockedBy": [
9
],
"model": "sonnet"
},
{
"id": 13,
"subject": "Task 13: Planned deployment record for QA vq03/vq04 and prod vd03",
"status": "pending",
"blockedBy": [
12
],
"model": "sonnet"
},
{
"id": 14,
"subject": "Task 14: Final cross-repo review (fable)",
"status": "pending",
"blockedBy": [
10,
11,
12,
13
],
"model": "fable"
}
],
"lastUpdated": "2026-09-02T18:40:27Z"
}
+26 -2
View File
@@ -21,7 +21,31 @@ Central cluster only. Method scripts execute on the **active** central node; a *
## API Key Management
### Storage
- API keys are stored in the **configuration database (MS SQL)**.
- API keys are stored in the shared `ZB.MOM.WW.Auth.ApiKeys` library's store — **SQLite per node by
default**, or the shared `zbauth` schema in the **configuration database (MS SQL)** on a central
pair when `Provider=SqlServer` (see *Key store provider* below). Revoke/rotate/scope changes are
cluster-wide immediately only under `SqlServer`; under `Sqlite` they apply to whichever node served
the management call.
### Key store provider
Config section `ScadaBridge:InboundApi:ApiKeyStore` (design 2026-09-02, register row 108):
| Key | Default | Notes |
|---|---|---|
| `Provider` | `Sqlite` | `Sqlite` (per node) or `SqlServer` (shared across a central pair). Case-insensitive; an unrecognized value fails startup via `StartupValidator`. |
| `SqlServer:ConnectionString` | `ScadaBridge:Database:ConfigurationDb` | Must be a literal or env override, **never** a `${secret:}` reference — the secrets store is itself SQL-backed, so resolving one through the other would be circular. |
| `SqlServer:Schema` | `zbauth` | Owns `schema_version`, `api_keys`, `api_key_audit`. |
| `SqlServer:CommandTimeout` | 30 s | |
| `ImportFromSqlitePath` | the library's `SqlitePath` default | SQLite file imported **once**, at boot, when `Provider=SqlServer`; insert-where-absent, so both nodes of a pair may import their own file and the result is the union. On success the file is renamed `*.imported` (plus `-wal`/`-shm`); on failure the import logs Error and the node keeps booting on the keys already in SQL. |
A Central node with ≥2 seed nodes still running `Provider=Sqlite` logs a boot-time Warning (not a hard
fail — availability-first, matching the auto-down decision) naming this design, because a failover
will otherwise 401 every inbound client until keys are re-issued. Both docker rigs (`docker/`,
`docker-env2/`) run `Provider=SqlServer`; QA/prod are a separate deployment record. See
`docs/operations/inbound-api-key-reissue.md` → *Central pairs* for the reissue/migration runbook and
`docs/operations/sql/zbauth-schema.sql` for the DDL a DBA runs by hand when the app login lacks
CREATE SCHEMA/TABLE rights.
### Key Properties
- **Name/Label**: Human-readable identifier for the key (e.g., "MES-Production", "RecipeManager-Dev").
@@ -306,7 +330,7 @@ A standby now **relays** the request to the active peer instead (`InboundApiEndp
## Dependencies
- **Configuration Database (MS SQL)**: Stores API keys and method definitions.
- **Configuration Database (MS SQL)**: Stores method definitions, and — under `Provider=SqlServer` — API keys in the `zbauth` schema (see *Key store provider* above). Under the `Sqlite` default, API keys live in a per-node SQLite file instead.
- **Communication Layer**: Routes requests to sites when method implementations need site data.
- **Security & Auth**: API key validation (separate from LDAP/AD — API uses key-based auth).
- **Configuration Database (via IAuditService)**: All API key and method definition changes are audit logged.
+3
View File
@@ -75,6 +75,9 @@ that is the same variable, not a separate one).
| `ScadaBridge__Security__JwtSigningKey` | Yes — `${secret:security/scadabridge/jwt-signing-key}` in `appsettings.Central.json` | `ScadaBridge:Security:JwtSigningKey` | HMAC-SHA256 signing key for the cookie-embedded session JWT. | ≥32-char secret | Yes on Central |
| `ScadaBridge__Security__Ldap__ServiceAccountPassword` | Yes — `${secret:ldap/scadabridge/service-account-password}` in `appsettings.Central.json` | `ScadaBridge:Security:Ldap:ServiceAccountPassword` | LDAP bind-account password. (Renamed from the old flat `...__LdapServiceAccountPassword`.) | the bind password | Yes on Central if LDAP bind is used |
| `ScadaBridge__InboundApi__ApiKeyPepper` | No — env/secret only (not in any shipped appsettings) | `ScadaBridge:InboundApi:ApiKeyPepper` | Per-environment pepper for the inbound API-key peppered-HMAC verifier. Hard Central startup requirement since 2026-06-03. | ≥16-char secret, unique per environment | Yes on Central |
| `ScadaBridge__InboundApi__ApiKeyStore__Provider` | Yes — `docker/docker-compose.yml` and `docker-env2/docker-compose.yml` set `SqlServer` on both centrals | `ScadaBridge:InboundApi:ApiKeyStore:Provider` | Which backend holds inbound API keys: `Sqlite` (per node, library default) or `SqlServer` (shared `zbauth` schema across a central pair — design 2026-09-02, register row 108). | `Sqlite` \| `SqlServer` (case-insensitive) | No — defaults to `Sqlite`; a clustered Central left on `Sqlite` logs a boot Warning |
| `ScadaBridge__InboundApi__ApiKeyStore__SqlServer__ConnectionString` | No — falls back to `ScadaBridge:Database:ConfigurationDb` when unset | `ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString` | Connection string for the shared `zbauth` schema. Only read when `Provider=SqlServer`. | connection string — literal or env override, **never** a `${secret:}` reference (the secrets store is itself SQL-backed) | No — only meaningful under `Provider=SqlServer` |
| `ScadaBridge__InboundApi__ApiKeyStore__ImportFromSqlitePath` | No — env/secret only | `ScadaBridge:InboundApi:ApiKeyStore:ImportFromSqlitePath` | SQLite file imported once at boot when `Provider=SqlServer`; renamed `*.imported` after a successful import. | filesystem path; defaults to the library's `SqlitePath` default | No |
| `ScadaBridge__Communication__GrpcPsk` | Yes — `${secret:SB-GRPC-PSK-site-1}` in `appsettings.Site.json` | `ScadaBridge:Communication:GrpcPsk` | **Site side** of the per-site gRPC control-plane PSK. StartupValidator refuses to boot a Site node without it; both pair nodes carry the same value. | per-site secret (e.g. `dev-grpc-psk-docker-site-a`); prod `${secret:SB-GRPC-PSK-<siteId>}` | Yes on Site |
| `ScadaBridge__Communication__SitePsks__<siteId>` | No in shipped src appsettings (central normally resolves the `SB-GRPC-PSK-<siteId>` secret); present only in the docker-compose env blocks | `ScadaBridge:Communication:SitePsks:<siteId>` | **Central side** of the same key: the value central presents when dialling that site. Must equal the site's `GrpcPsk`. Override for hosts without a master secret store. | matches the site's `GrpcPsk` | On Central, either this **or** the `SB-GRPC-PSK-<siteId>` store secret |