b5da1c8c57
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
81 lines
3.6 KiB
C#
81 lines
3.6 KiB
C#
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;
|
|
}
|
|
}
|