using Microsoft.Data.Sqlite; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; /// /// SQLite-backed administration store for API keys (create, revoke, rotate, delete, /// set-scopes, enable/disable). /// public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore { /// public async Task CreateAsync(ApiKeyRecord record, CancellationToken ct) { ArgumentNullException.ThrowIfNull(record); await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ INSERT INTO api_keys ( key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc) VALUES ( $key_id, $key_prefix, $secret_hash, $display_name, $scopes, $constraints, $created_utc, $last_used_utc, $revoked_utc); """; command.Parameters.AddWithValue("$key_id", record.KeyId); command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix); command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash; command.Parameters.AddWithValue("$display_name", record.DisplayName); command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes)); command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value); 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); await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } /// public async Task RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ UPDATE api_keys SET revoked_utc = $revoked_utc WHERE key_id = $key_id AND revoked_utc IS NULL; """; command.Parameters.AddWithValue("$key_id", keyId); command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O")); int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); return rows > 0; } /// public async Task RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); ArgumentNullException.ThrowIfNull(newSecretHash); await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ UPDATE api_keys SET secret_hash = $secret_hash, last_used_utc = NULL, revoked_utc = NULL WHERE key_id = $key_id; """; command.Parameters.AddWithValue("$key_id", keyId); command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = newSecretHash; int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); return rows > 0; } /// public async Task SetScopesAsync(string keyId, IReadOnlySet scopes, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); ArgumentNullException.ThrowIfNull(scopes); await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ UPDATE api_keys SET scopes = $scopes WHERE key_id = $key_id; """; command.Parameters.AddWithValue("$key_id", keyId); command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(scopes)); int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); return rows > 0; } /// public async Task SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); // Reversible toggle: NO `revoked_utc IS NULL` guard (unlike RevokeAsync), so it works // regardless of current state. Deliberately leaves secret_hash and last_used_utc untouched // — that is what distinguishes re-enable from RotateAsync. if (enabled) { command.CommandText = """ UPDATE api_keys SET revoked_utc = NULL WHERE key_id = $key_id; """; command.Parameters.AddWithValue("$key_id", keyId); } else { command.CommandText = """ UPDATE api_keys SET revoked_utc = $revoked_utc WHERE key_id = $key_id; """; command.Parameters.AddWithValue("$key_id", keyId); command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O")); } int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); return rows > 0; } /// public async Task DeleteAsync(string keyId, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ DELETE FROM api_keys WHERE key_id = $key_id AND revoked_utc IS NOT NULL; """; command.Parameters.AddWithValue("$key_id", keyId); int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); return rows > 0; } /// public async Task> ListAsync(CancellationToken ct) { await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); // 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 FROM api_keys ORDER BY created_utc DESC, key_id DESC; """; List items = []; await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await reader.ReadAsync(ct).ConfigureAwait(false)) { items.Add(new ApiKeyListItem( KeyId: reader.GetString(0), KeyPrefix: reader.GetString(1), DisplayName: reader.GetString(2), Scopes: ScopeSerializer.Deserialize(reader.GetString(3)), ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4), CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)), LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6), RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7))); } return items; } }