using System.Data; using Microsoft.Data.SqlClient; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer; /// /// SQL Server-backed, append-only audit store for API-key events. A one-for-one port of /// SqliteApiKeyAuditStore; 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. /// /// Factory for connections to the shared API-key database. public sealed class SqlServerApiKeyAuditStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyAuditStore { /// 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); } /// /// /// Ordered by the IDENTITY audit_id, not by created_utc: two events stamped in the /// same instant still come back in insertion order. A non-positive limit short-circuits without /// opening a connection — SELECT TOP (-1) is not even legal T-SQL. /// public async Task> 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 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; } }