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
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Schema constants and DDL for the shared SQL Server API-key store. Mirrors
|
||||
/// <c>ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthSchema</c> column-for-column so a row means exactly
|
||||
/// the same thing in either store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Timestamps are <c>DATETIMEOFFSET(7)</c>, not ISO-8601 text.</b> The sibling
|
||||
/// <c>ZB.MOM.WW.Secrets</c> SQL Server store deliberately stores its timestamps as
|
||||
/// <c>nvarchar</c> because its rows <em>replicate</em> with a SQLite store and last-writer-wins
|
||||
/// compares those timestamps for equality, where a sub-tick round-trip difference would turn a tie
|
||||
/// into a spurious "newer". No such rationale applies here: this store never replicates with the
|
||||
/// SQLite one — a host picks exactly one provider, and the one-shot SQLite import is a copy, not an
|
||||
/// ongoing sync — so the native type is used, which sorts and range-scans correctly and keeps the
|
||||
/// tables readable to a DBA.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>SqliteAuthSchema.CurrentVersion</c> is 3 only because that store carries the donor
|
||||
/// MxAccessGateway's version history (v1/v2 were byte-identical; v3 added <c>expires_utc</c>). This
|
||||
/// schema is created whole, with <c>expires_utc</c> present from the start, so its own version line
|
||||
/// starts at 1.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SqlServerAuthSchema
|
||||
{
|
||||
/// <summary>
|
||||
/// The schema version this build creates and supports. The migrator refuses a database whose
|
||||
/// recorded version is <em>newer</em> than this.
|
||||
/// </summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
/// <summary>Default schema the API-key tables live in.</summary>
|
||||
public const string DefaultSchemaName = "zbauth";
|
||||
|
||||
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
/// <summary>Name of the table storing API-key records.</summary>
|
||||
public const string ApiKeysTable = "api_keys";
|
||||
|
||||
/// <summary>Name of the append-only audit table.</summary>
|
||||
public const string ApiKeyAuditTable = "api_key_audit";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the idempotent DDL that provisions the schema, the three tables and the two
|
||||
/// supporting indexes in <paramref name="schemaName"/>.
|
||||
/// </summary>
|
||||
/// <param name="schemaName">The SQL schema name to create the objects in.</param>
|
||||
/// <returns>The DDL batch.</returns>
|
||||
/// <remarks>
|
||||
/// <paramref name="schemaName"/> is interpolated, not parameterized — T-SQL has no parameter
|
||||
/// form for an object name. It is re-validated here against the same strict identifier
|
||||
/// allow-list <see cref="SqlServerApiKeyOptions.Validate"/> enforces, and bracket-quoted below.
|
||||
/// The duplicate check is deliberate: this method is <c>public</c>, so it must defend itself
|
||||
/// rather than trust that every caller routed through the options type. The version row is NOT
|
||||
/// written here — <see cref="SqlServerApiKeyStoreMigrator"/> writes it with a MERGE that also
|
||||
/// updates on match, so a future upgrade re-stamps the stored version.
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException"><paramref name="schemaName"/> is not a plain identifier.</exception>
|
||||
public static string CreateSchemaDdl(string schemaName)
|
||||
{
|
||||
SqlServerApiKeyOptions.ValidateIdentifier(schemaName);
|
||||
|
||||
return $"""
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}')
|
||||
EXEC(N'CREATE SCHEMA [{schemaName}]');
|
||||
|
||||
IF OBJECT_ID(N'[{schemaName}].[{SchemaVersionTable}]', N'U') IS NULL
|
||||
CREATE TABLE [{schemaName}].[{SchemaVersionTable}] (
|
||||
id INT NOT NULL CONSTRAINT PK_{schemaName}_{SchemaVersionTable} PRIMARY KEY CHECK (id = 1),
|
||||
version INT NOT NULL,
|
||||
applied_utc DATETIMEOFFSET(7) NOT NULL
|
||||
);
|
||||
|
||||
IF OBJECT_ID(N'[{schemaName}].[{ApiKeysTable}]', N'U') IS NULL
|
||||
CREATE TABLE [{schemaName}].[{ApiKeysTable}] (
|
||||
key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_{schemaName}_{ApiKeysTable} 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'[{schemaName}].[{ApiKeyAuditTable}]', N'U') IS NULL
|
||||
CREATE TABLE [{schemaName}].[{ApiKeyAuditTable}] (
|
||||
audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_{schemaName}_{ApiKeyAuditTable} 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'[{schemaName}].[{ApiKeysTable}]'))
|
||||
CREATE INDEX ix_api_keys_revoked_utc
|
||||
ON [{schemaName}].[{ApiKeysTable}] (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'[{schemaName}].[{ApiKeyAuditTable}]'))
|
||||
CREATE INDEX ix_api_key_audit_key_id_created_utc
|
||||
ON [{schemaName}].[{ApiKeyAuditTable}] (key_id, created_utc);
|
||||
""";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user