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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user