using System.Text.Json; using ZB.MOM.WW.Audit; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; /// /// Adapter that overrides the shared library's so that /// library-emitted API-key audit events (CLI / admin verbs from /// ApiKeyAdminCommands) are canonicalized onto and routed /// through the gateway's into the canonical /// audit_event store. /// /// /// Overriding the registered is the ONLY way to /// canonicalize the library-internal ApiKeyAdminCommands events, since that type /// cannot be edited. reads back from the canonical store /// and maps each to an so the /// existing dashboard "recent audit" view (and the CLI/store tests) keep working through /// this same seam, unchanged. /// /// The library's own api_key_audit table is left in place but UNUSED — nothing /// writes to it once this adapter overrides the library's SqliteApiKeyAuditStore /// registration. /// /// public sealed class CanonicalForwardingApiKeyAuditStore( IAuditWriter auditWriter, SqliteCanonicalAuditStore store) : IApiKeyAuditStore { /// The canonical assigned to API-key events. public const string ApiKeyCategory = "ApiKey"; /// Actor used for the library's keyless init-db event. private const string SystemActor = "system"; /// Actor used for any other keyless (CLI-originated) library event. private const string CliActor = "cli"; /// The library event type that denotes a constraint denial. private const string ConstraintDeniedEventType = "constraint-denied"; /// The library's keyless schema-init event type. private const string InitDbEventType = "init-db"; /// public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct) { ArgumentNullException.ThrowIfNull(entry); AuditEvent auditEvent = new() { EventId = Guid.NewGuid(), OccurredAtUtc = entry.CreatedUtc, // Keyless library events: init-db is system-originated; any other keyless event // is a CLI/admin verb run without an authenticated principal. Actor = entry.KeyId ?? (entry.EventType == InitDbEventType ? SystemActor : CliActor), Action = entry.EventType, Outcome = entry.EventType == ConstraintDeniedEventType ? AuditOutcome.Denied : AuditOutcome.Success, Category = ApiKeyCategory, Target = entry.KeyId, SourceNode = entry.RemoteAddress, CorrelationId = null, DetailsJson = WrapDetails(entry.Details), }; // Best-effort: IAuditWriter swallows/logs failures, so this never throws. await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false); } /// public async Task> ListRecentAsync(int limit, CancellationToken ct) { IReadOnlyList events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false); ApiKeyAuditEntry[] entries = new ApiKeyAuditEntry[events.Count]; for (int index = 0; index < events.Count; index++) { AuditEvent auditEvent = events[index]; entries[index] = new ApiKeyAuditEntry( KeyId: auditEvent.Actor switch { // Keyless library events were mapped to the system/cli sentinel actors on the // way in; map them back to a null KeyId so the dashboard view is faithful. SystemActor or CliActor => null, string actor => actor, }, EventType: auditEvent.Action, RemoteAddress: auditEvent.SourceNode, CreatedUtc: auditEvent.OccurredAtUtc, Details: UnwrapDetails(auditEvent.DetailsJson)); } return entries; } /// /// Wraps a free-form library detail string into the canonical /// {"detail": "<escaped>"} JSON envelope, or null when there is no detail. /// private static string? WrapDetails(string? details) { if (details is null) { return null; } return JsonSerializer.Serialize(new Dictionary { ["detail"] = details }); } /// /// Unwraps the canonical detail envelope back to the original free-form string. Falls /// back to the raw JSON when it is not a recognised {"detail": ...} envelope, so /// directly-emitted canonical events (whose DetailsJson is richer) still surface text. /// private static string? UnwrapDetails(string? detailsJson) { if (string.IsNullOrEmpty(detailsJson)) { return null; } try { using JsonDocument document = JsonDocument.Parse(detailsJson); if (document.RootElement.ValueKind == JsonValueKind.Object && document.RootElement.TryGetProperty("detail", out JsonElement detail) && detail.ValueKind == JsonValueKind.String) { return detail.GetString(); } } catch (JsonException) { // Not JSON we recognise; surface the raw payload below. } return detailsJson; } }