Files
ScadaBridge/docs/plans/2026-09-02-inbound-api-key-shared-store-design.md
T

119 lines
8.8 KiB
Markdown

# 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.