139 lines
6.6 KiB
C#
139 lines
6.6 KiB
C#
using System.Globalization;
|
|
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
|
|
|
/// <summary>
|
|
/// MxGateway-owned, append-only SQLite store for canonical
|
|
/// <see cref="AuditEvent"/>s. It writes to a NEW <c>audit_event</c> table in the
|
|
/// SAME database file as the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> stores: both share
|
|
/// the library's <see cref="AuthSqliteConnectionFactory"/> (so they target the same
|
|
/// <c>ApiKeyOptions.SqlitePath</c> with the same WAL/busy-timeout connection config).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This store is the canonical sink for ALL MxGateway audit. The library's own
|
|
/// <c>api_key_audit</c> table is left in place but UNUSED after adoption — the library's
|
|
/// <c>IApiKeyAuditStore</c> registration is overridden by
|
|
/// <see cref="CanonicalForwardingApiKeyAuditStore"/>, which forwards onto this store via
|
|
/// <see cref="CanonicalAuditWriter"/>. The library's <c>schema_version</c> /
|
|
/// <c>api_key_audit</c> tables are not touched here; the <c>audit_event</c> table is
|
|
/// created idempotently (<c>CREATE TABLE IF NOT EXISTS</c>) on each write so it
|
|
/// self-bootstraps regardless of migration ordering.
|
|
/// </remarks>
|
|
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory)
|
|
{
|
|
private const string CreateTableSql =
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS audit_event (
|
|
event_id TEXT PRIMARY KEY,
|
|
occurred_at_utc TEXT NOT NULL,
|
|
actor TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
outcome TEXT NOT NULL,
|
|
category TEXT NULL,
|
|
target TEXT NULL,
|
|
source_node TEXT NULL,
|
|
correlation_id TEXT NULL,
|
|
details_json TEXT NULL
|
|
);
|
|
""";
|
|
|
|
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
|
|
/// <param name="auditEvent">The canonical event to persist.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
|
|
|
await using SqliteConnection connection =
|
|
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
|
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
command.CommandText =
|
|
"""
|
|
INSERT INTO audit_event
|
|
(event_id, occurred_at_utc, actor, action, outcome,
|
|
category, target, source_node, correlation_id, details_json)
|
|
VALUES
|
|
($event_id, $occurred_at_utc, $actor, $action, $outcome,
|
|
$category, $target, $source_node, $correlation_id, $details_json);
|
|
""";
|
|
command.Parameters.AddWithValue("$event_id", auditEvent.EventId.ToString());
|
|
command.Parameters.AddWithValue("$occurred_at_utc", auditEvent.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
|
command.Parameters.AddWithValue("$actor", auditEvent.Actor);
|
|
command.Parameters.AddWithValue("$action", auditEvent.Action);
|
|
command.Parameters.AddWithValue("$outcome", auditEvent.Outcome.ToString());
|
|
command.Parameters.AddWithValue("$category", (object?)auditEvent.Category ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("$target", (object?)auditEvent.Target ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("$source_node", (object?)auditEvent.SourceNode ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("$correlation_id", (object?)auditEvent.CorrelationId?.ToString() ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("$details_json", (object?)auditEvent.DetailsJson ?? DBNull.Value);
|
|
|
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
|
|
/// <param name="limit">Maximum number of events to return.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
|
|
{
|
|
if (limit <= 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
await using SqliteConnection connection =
|
|
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
|
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
command.CommandText =
|
|
"""
|
|
SELECT event_id, occurred_at_utc, actor, action, outcome,
|
|
category, target, source_node, correlation_id, details_json
|
|
FROM audit_event
|
|
ORDER BY rowid DESC
|
|
LIMIT $limit;
|
|
""";
|
|
command.Parameters.AddWithValue("$limit", limit);
|
|
|
|
List<AuditEvent> events = [];
|
|
|
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
|
{
|
|
events.Add(new AuditEvent
|
|
{
|
|
EventId = Guid.Parse(reader.GetString(0)),
|
|
OccurredAtUtc = ParseUtc(reader.GetString(1)),
|
|
Actor = reader.GetString(2),
|
|
Action = reader.GetString(3),
|
|
Outcome = Enum.Parse<AuditOutcome>(reader.GetString(4)),
|
|
Category = reader.IsDBNull(5) ? null : reader.GetString(5),
|
|
Target = reader.IsDBNull(6) ? null : reader.GetString(6),
|
|
SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
|
|
CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
|
|
DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
|
|
});
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
private static async Task EnsureTableAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
|
{
|
|
await using SqliteCommand command = connection.CreateCommand();
|
|
command.CommandText = CreateTableSql;
|
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static DateTimeOffset ParseUtc(string value) =>
|
|
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
|
}
|