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
+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);