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
@@ -21,10 +21,10 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
command.CommandText = """
INSERT INTO api_keys (
key_id, key_prefix, secret_hash, display_name, scopes,
constraints, created_utc, last_used_utc, revoked_utc)
constraints, created_utc, last_used_utc, revoked_utc, expires_utc)
VALUES (
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
$constraints, $created_utc, $last_used_utc, $revoked_utc);
$constraints, $created_utc, $last_used_utc, $revoked_utc, $expires_utc);
""";
command.Parameters.AddWithValue("$key_id", record.KeyId);
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
@@ -35,6 +35,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
command.Parameters.AddWithValue("$expires_utc", (object?)record.ExpiresUtc?.ToString("O") ?? DBNull.Value);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
@@ -178,7 +179,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
// Deliberately omits secret_hash so listing can never leak secret material.
command.CommandText = """
SELECT key_id, key_prefix, display_name, scopes, constraints,
created_utc, last_used_utc, revoked_utc
created_utc, last_used_utc, revoked_utc, expires_utc
FROM api_keys
ORDER BY created_utc DESC, key_id DESC;
""";
@@ -197,7 +198,8 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)),
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6),
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7)));
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 8)));
}
return items;
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
{
private const string SelectColumns =
"key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc";
"key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc, expires_utc";
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
@@ -72,5 +72,6 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)),
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8));
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8),
ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 9));
}
@@ -6,14 +6,16 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
public static class SqliteAuthSchema
{
/// <summary>
/// The schema version this build creates and supports. This is <c>2</c>, not <c>1</c>,
/// to match the deployed databases of the donor (MxAccessGateway) this store was
/// extracted from: that store reached its final shape via a v1→v2 history and stamps
/// <c>version = 2</c> on disk. The final schema has been byte-identical since v1, so a
/// single-shot create stamped as 2 interoperates with existing <c>gateway-auth.db</c>
/// files (the migrator only refuses an on-disk version <em>newer</em> than this).
/// The schema version this build creates and supports. Versions <c>1</c> and <c>2</c> shared a
/// byte-identical schema (the store was extracted from the donor MxAccessGateway, whose deployed
/// <c>gateway-auth.db</c> stamps <c>version = 2</c>); <c>3</c> adds the nullable
/// <c>expires_utc</c> column (API-key expiry, archreview G-2). The migration is additive and
/// backward-compatible: an existing v1/v2 database is upgraded in place (the column is added via
/// an idempotent <c>ALTER TABLE</c>, existing rows get <c>NULL</c> = never-expires) and re-stamped
/// to 3, so no key is invalidated on upgrade. The migrator still only refuses an on-disk version
/// <em>newer</em> than this, so a donor v2 database is accepted (2 &lt;= 3) and upgraded.
/// </summary>
public const int CurrentVersion = 2;
public const int CurrentVersion = 3;
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version";
@@ -34,6 +36,11 @@ public static class SqliteAuthSchema
""";
/// <summary>DDL creating the API-key record table.</summary>
/// <remarks>
/// <c>expires_utc</c> (nullable) was added at schema v3. Fresh databases get it from this DDL;
/// existing v1/v2 databases get it via the idempotent <c>ALTER TABLE</c> in
/// <see cref="SqliteAuthStoreMigrator"/> (<see cref="AddExpiresUtcColumn"/>).
/// </remarks>
public const string CreateApiKeysTable = """
CREATE TABLE IF NOT EXISTS api_keys (
key_id TEXT PRIMARY KEY,
@@ -44,10 +51,22 @@ public static class SqliteAuthSchema
constraints TEXT NULL,
created_utc TEXT NOT NULL,
last_used_utc TEXT NULL,
revoked_utc TEXT NULL
revoked_utc TEXT NULL,
expires_utc TEXT NULL
);
""";
/// <summary>Name of the nullable expiry column added at schema v3.</summary>
public const string ExpiresUtcColumn = "expires_utc";
/// <summary>
/// Idempotent forward migration adding <see cref="ExpiresUtcColumn"/> to a pre-v3
/// <c>api_keys</c> table. Guarded by a column-existence check at the call site, so it is safe to
/// run against a fresh (already-has-the-column) database too.
/// </summary>
public const string AddExpiresUtcColumn =
"ALTER TABLE api_keys ADD COLUMN expires_utc TEXT NULL;";
/// <summary>DDL creating the append-only audit table.</summary>
public const string CreateApiKeyAuditTable = """
CREATE TABLE IF NOT EXISTS api_key_audit (
@@ -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(