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