206 lines
8.1 KiB
C#
206 lines
8.1 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
|
|
|
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
|
|
|
/// <summary>
|
|
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete,
|
|
/// set-scopes, enable/disable).
|
|
/// </summary>
|
|
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
|
|
{
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<ApiKeyListItem>> 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<ApiKeyListItem> 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;
|
|
}
|
|
}
|