Files
scadaproj/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs
T

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;
}
}