fe9044115b
Server-007: GalaxyHierarchyProjector re-filtered the whole hierarchy per page (O(total) paging). It now memoizes the filtered list per cache-entry + filter signature so subsequent pages are an O(pageSize) slice. Server-008: WatchDeployEvents re-resolved browse subtrees and rebuilt globs per streamed event. ResolveBrowseSubtrees is hoisted out of the loop and GalaxyGlobMatcher caches compiled Regex instances per pattern. Server-009: auth-store connections used no busy timeout or WAL. A new OpenConnectionAsync applies journal_mode=WAL and a busy_timeout; all auth call sites use it. docs/Authentication.md updated. Server-010: the dashboard rendered Rotate/Revoke for revoked keys, where Rotate silently reactivates them. ApiKeysPage now shows actions only for Active keys. docs/Authentication.md updated. Server-011: WorkerAlarmRpcDispatcher converted to a primary constructor and brought in line with module conventions. Server-012: CLAUDE.md corrected to the canonical *:* scope strings. Server-013 (partly re-triaged): three named coverage gaps were already closed; the genuine gap (WorkerExecutableValidator) is now covered. Server-014: rewrote stale "alarm path not yet wired" comments in MxAccessGatewayService to describe the production WorkerAlarmRpcDispatcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
4.7 KiB
C#
125 lines
4.7 KiB
C#
using Microsoft.Data.Sqlite;
|
|
|
|
namespace MxGateway.Server.Security.Authentication;
|
|
|
|
/// <summary>
|
|
/// SQLite-backed storage for API key administration (create, list, revoke, rotate).
|
|
/// </summary>
|
|
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
|
|
{
|
|
/// <inheritdoc />
|
|
public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
|
{
|
|
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).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,
|
|
NULL,
|
|
NULL);
|
|
""";
|
|
AddCreateParameters(command, request);
|
|
|
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
|
{
|
|
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
command.CommandText = """
|
|
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
|
FROM api_keys
|
|
ORDER BY key_id;
|
|
""";
|
|
|
|
List<ApiKeyRecord> records = [];
|
|
|
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
|
{
|
|
records.Add(ApiKeyRecordReader.Read(reader));
|
|
}
|
|
|
|
return records;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken)
|
|
{
|
|
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).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", revokedUtc.ToString("O"));
|
|
|
|
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return rows > 0;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> RotateAsync(
|
|
string keyId,
|
|
byte[] secretHash,
|
|
DateTimeOffset rotatedUtc,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).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 = secretHash;
|
|
|
|
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return rows > 0;
|
|
}
|
|
|
|
private static void AddCreateParameters(SqliteCommand command, ApiKeyCreateRequest request)
|
|
{
|
|
command.Parameters.AddWithValue("$key_id", request.KeyId);
|
|
command.Parameters.AddWithValue("$key_prefix", request.KeyPrefix);
|
|
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash;
|
|
command.Parameters.AddWithValue("$display_name", request.DisplayName);
|
|
command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes));
|
|
command.Parameters.AddWithValue(
|
|
"$constraints",
|
|
(object?)ApiKeyConstraintSerializer.Serialize(request.Constraints) ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O"));
|
|
}
|
|
}
|