feat(apikeys): optional ExpiresUtc — create + verifier enforcement + sqlite v3 migration (archreview G-2)

ApiKeyRecord/ApiKeyListItem gain a nullable ExpiresUtc (NULL = never
expires); ApiKeyFailure gains KeyExpired. ApiKeyVerifier rejects a key
whose ExpiresUtc is at-or-before now (inclusive, injected clock), before
the secret comparison. CreateKeyAsync gets an expiresUtc overload; rotate
preserves existing expiry. SQLite schema bumps to v3: a nullable
expires_utc column, added to fresh DBs via CREATE and to existing v1/v2
DBs via an idempotent guarded ALTER — donor v2 gateway-auth.db upgrades
in place, no key invalidated. Version 0.1.3 -> 0.1.4 (not yet published;
nuget push is human-gated). Consumers (HistorianGateway, mxaccessgw) bump
to 0.1.4 to set/enforce expiry. 146 Auth.ApiKeys tests pass.
This commit is contained in:
Joseph Doherty
2026-07-09 06:04:54 -04:00
parent 497c0aac52
commit 378880f0ed
14 changed files with 381 additions and 32 deletions
@@ -79,7 +79,8 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
}
// Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is
// idempotent against an already-provisioned database). The applied version is stamped
// idempotent against an already-provisioned database), followed by any additive column
// migrations that a pre-existing table would be missing. The applied version is stamped
// separately by WriteSchemaVersionAsync.
private static async Task ApplySchemaAsync(
SqliteConnection connection,
@@ -96,6 +97,54 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
SqliteAuthSchema.CreateApiKeyAuditTable,
SqliteAuthSchema.CreateIndexes),
cancellationToken).ConfigureAwait(false);
// v3: add the nullable expires_utc column to a pre-v3 api_keys table. On a fresh database the
// CREATE above already includes the column, so the existence check makes this a no-op.
await EnsureApiKeysColumnAsync(
connection,
transaction,
SqliteAuthSchema.ExpiresUtcColumn,
SqliteAuthSchema.AddExpiresUtcColumn,
cancellationToken).ConfigureAwait(false);
}
// Adds a column to api_keys only if it is not already present. SQLite has no
// "ADD COLUMN IF NOT EXISTS", so we probe PRAGMA table_info first. The reader is fully consumed
// and disposed before the ALTER runs on the same connection/transaction.
private static async Task EnsureApiKeysColumnAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string columnName,
string addColumnDdl,
CancellationToken cancellationToken)
{
bool exists = false;
await using (SqliteCommand pragma = connection.CreateCommand())
{
pragma.Transaction = transaction;
// table_info takes an identifier, not a bindable parameter; the table name is a fixed
// internal constant, never caller input.
pragma.CommandText = $"PRAGMA table_info({SqliteAuthSchema.ApiKeysTable});";
await using SqliteDataReader reader =
await pragma.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
// PRAGMA table_info columns: cid(0), name(1), type(2), ...
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
await ExecuteNonQueryAsync(connection, transaction, addColumnDdl, cancellationToken).ConfigureAwait(false);
}
}
private static async Task WriteSchemaVersionAsync(