feat(auth.apikeys.sqlserver): IApiKeyStore/IApiKeyAdminStore/IApiKeyAuditStore over SQL Server, semantics pinned by the SQLite contract tests re-run live
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
using System.Data;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server-backed, append-only audit store for API-key events. A one-for-one port of
|
||||
/// <c>SqliteApiKeyAuditStore</c>; on a clustered host the rows from every node land in one table,
|
||||
/// so the audit trail no longer has to be stitched together per node.
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">Factory for connections to the shared API-key database.</param>
|
||||
public sealed class SqlServerApiKeyAuditStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyAuditStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
string schema = connectionFactory.Schema;
|
||||
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
|
||||
INSERT INTO [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}] (
|
||||
key_id, event_type, remote_address, created_utc, details)
|
||||
VALUES (@key_id, @event_type, @remote_address, @created_utc, @details);
|
||||
""");
|
||||
command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = (object?)entry.KeyId ?? DBNull.Value;
|
||||
command.Parameters.Add("@event_type", SqlDbType.NVarChar, 64).Value = entry.EventType;
|
||||
command.Parameters.Add("@remote_address", SqlDbType.NVarChar, 64).Value = (object?)entry.RemoteAddress ?? DBNull.Value;
|
||||
command.Parameters.Add("@created_utc", SqlDbType.DateTimeOffset).Value = entry.CreatedUtc;
|
||||
command.Parameters.Add("@details", SqlDbType.NVarChar, -1).Value = (object?)entry.Details ?? DBNull.Value;
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Ordered by the IDENTITY <c>audit_id</c>, not by <c>created_utc</c>: two events stamped in the
|
||||
/// same instant still come back in insertion order. A non-positive limit short-circuits without
|
||||
/// opening a connection — <c>SELECT TOP (-1)</c> is not even legal T-SQL.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
string schema = connectionFactory.Schema;
|
||||
|
||||
await using SqlConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqlCommand command = connectionFactory.CreateCommand(connection, $"""
|
||||
SELECT TOP (@limit) key_id, event_type, remote_address, created_utc, details
|
||||
FROM [{schema}].[{SqlServerAuthSchema.ApiKeyAuditTable}]
|
||||
ORDER BY audit_id DESC;
|
||||
""");
|
||||
command.Parameters.Add("@limit", SqlDbType.Int).Value = limit;
|
||||
|
||||
List<ApiKeyAuditEntry> entries = [];
|
||||
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
entries.Add(new ApiKeyAuditEntry(
|
||||
KeyId: reader.IsDBNull(0) ? null : reader.GetString(0),
|
||||
EventType: reader.GetString(1),
|
||||
RemoteAddress: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
CreatedUtc: reader.GetDateTimeOffset(3),
|
||||
Details: reader.IsDBNull(4) ? null : reader.GetString(4)));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user