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:
@@ -8,7 +8,7 @@ public sealed record ApiKeyOptions
|
||||
public bool RunMigrationsOnStartup { get; init; } = true;
|
||||
}
|
||||
|
||||
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
|
||||
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch, KeyExpired }
|
||||
|
||||
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);
|
||||
|
||||
@@ -29,7 +29,10 @@ public interface IApiKeyVerifier
|
||||
public sealed record ApiKeyRecord(
|
||||
string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName,
|
||||
IReadOnlySet<string> Scopes, string? ConstraintsJson,
|
||||
DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
|
||||
DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc,
|
||||
// Optional absolute expiry. NULL = never expires (the default for every key minted before this
|
||||
// field existed, so upgrading a store leaves existing keys valid). Enforced in ApiKeyVerifier.
|
||||
DateTimeOffset? ExpiresUtc = null);
|
||||
|
||||
public interface IApiKeyStore
|
||||
{
|
||||
@@ -46,7 +49,9 @@ public sealed record ApiKeyAuditEntry(string? KeyId, string EventType, string? R
|
||||
/// </summary>
|
||||
public sealed record ApiKeyListItem(
|
||||
string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet<string> Scopes,
|
||||
string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
|
||||
string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc,
|
||||
// Optional absolute expiry (NULL = never expires); surfaced so admins can see/alert on expiry.
|
||||
DateTimeOffset? ExpiresUtc = null);
|
||||
|
||||
public interface IApiKeyAdminStore
|
||||
{
|
||||
|
||||
@@ -79,11 +79,27 @@ public sealed class ApiKeyAdminCommands
|
||||
/// and returns the assembled token <c><prefix>_<keyId>_<secret></c> EXACTLY ONCE.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public Task<CreateKeyResult> CreateKeyAsync(
|
||||
string keyId,
|
||||
string displayName,
|
||||
IReadOnlySet<string> scopes,
|
||||
string? constraintsJson,
|
||||
string? remoteAddress,
|
||||
CancellationToken ct) =>
|
||||
CreateKeyAsync(keyId, displayName, scopes, constraintsJson, expiresUtc: null, remoteAddress, ct);
|
||||
|
||||
/// <summary>
|
||||
/// create-key with an optional absolute expiry. A non-null <paramref name="expiresUtc"/> is
|
||||
/// persisted on the record and enforced by <see cref="ApiKeyVerifier"/>; <c>null</c> means the
|
||||
/// key never expires (the same behaviour as the expiry-less overload).
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public async Task<CreateKeyResult> CreateKeyAsync(
|
||||
string keyId,
|
||||
string displayName,
|
||||
IReadOnlySet<string> scopes,
|
||||
string? constraintsJson,
|
||||
DateTimeOffset? expiresUtc,
|
||||
string? remoteAddress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -111,7 +127,8 @@ public sealed class ApiKeyAdminCommands
|
||||
ConstraintsJson: constraintsJson,
|
||||
CreatedUtc: now,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null);
|
||||
RevokedUtc: null,
|
||||
ExpiresUtc: expiresUtc);
|
||||
|
||||
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
|
||||
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, ct).ConfigureAwait(false);
|
||||
@@ -151,6 +168,11 @@ public sealed class ApiKeyAdminCommands
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rotation replaces only the secret (and clears revoked/last-used); it deliberately leaves any
|
||||
/// existing <see cref="ApiKeyRecord.ExpiresUtc"/> untouched, so a key's expiry survives a secret
|
||||
/// rotation. Changing a key's expiry is a create-time concern (use <see cref="CreateKeyAsync(string,string,IReadOnlySet{string},string?,DateTimeOffset?,string?,CancellationToken)"/>).
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -56,6 +56,14 @@ public sealed class ApiKeyVerifier(
|
||||
return Fail(ApiKeyFailure.KeyRevoked);
|
||||
}
|
||||
|
||||
// 4b. Reject expired keys. Expiry is absolute and inclusive: a key whose ExpiresUtc is at or
|
||||
// before "now" is expired. A NULL ExpiresUtc never expires (the pre-expiry default), so
|
||||
// upgrading a store leaves existing keys valid. Uses the injected clock for testability.
|
||||
if (record.ExpiresUtc is { } expiresUtc && expiresUtc <= _timeProvider.GetUtcNow())
|
||||
{
|
||||
return Fail(ApiKeyFailure.KeyExpired);
|
||||
}
|
||||
|
||||
// 5. Constant-time secret comparison.
|
||||
if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
|
||||
{
|
||||
|
||||
@@ -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