From a947c8df6ed780e40fe36a0768f7a9edcdde8601 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 19 Apr 2026 22:35:47 +0300 Subject: [PATCH] feat(authority): dual-write audit events to Timeline unified sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint SPRINT_20260408_005 DEPRECATE-001 (Authority, first service). AuthorityAuditSink.WriteAsync now fans out to Timeline's unified audit store via the optional IAuditEventEmitter (injected via AddAuditEmission in Program.cs). The local authority.audit table write remains the authoritative path; the Timeline emission is strictly fire-and-forget: - Optional constructor dependency (default null) keeps existing tests that construct the sink without the emitter working unchanged. - Emission is wrapped in try/catch so any Timeline-side failure (DNS, timeout, auth) is logged as a warning and never impacts the local write or calling endpoint. - MapToTimelinePayload builds a UnifiedAuditEvent-compatible payload with actor (subject id/name/IP/UA), resource (authority_session keyed by correlationId), severity derived from outcome, and event details including client, reason, and event type. Existing AuthorityAuditSinkTests (2/2) still pass — backward compat verified via direct xUnit run. Remaining DEPRECATE-001 work: Policy, Notify, Scheduler, JobEngine, Attestor dual-write wiring on the same pattern. Tracked as follow-ups. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audit/AuthorityAuditSink.cs | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Audit/AuthorityAuditSink.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Audit/AuthorityAuditSink.cs index 9aa598483..0d5a517c2 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Audit/AuthorityAuditSink.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Audit/AuthorityAuditSink.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; +using StellaOps.Audit.Emission; using StellaOps.Authority.Persistence.Documents; using StellaOps.Authority.Persistence.InMemory.Stores; using StellaOps.Cryptography.Audit; @@ -18,13 +19,16 @@ internal sealed class AuthorityAuditSink : IAuthEventSink private readonly IAuthorityLoginAttemptStore loginAttemptStore; private readonly ILogger logger; + private readonly IAuditEventEmitter? timelineEmitter; public AuthorityAuditSink( IAuthorityLoginAttemptStore loginAttemptStore, - ILogger logger) + ILogger logger, + IAuditEventEmitter? timelineEmitter = null) { this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.timelineEmitter = timelineEmitter; } public async ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) @@ -42,6 +46,80 @@ internal sealed class AuthorityAuditSink : IAuthEventSink var document = MapToDocument(record); await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false); + + // DEPRECATE-001: dual-write to Timeline unified audit sink. Fire-and-forget; + // any failure is already logged by HttpAuditEventEmitter and must never + // affect the local write or calling endpoint. + if (timelineEmitter is not null) + { + try + { + var payload = MapToTimelinePayload(record); + await timelineEmitter.EmitAsync(payload, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to emit authority audit event to Timeline (local write succeeded)"); + } + } + } + + private static AuditEventPayload MapToTimelinePayload(AuthEventRecord record) + { + var outcome = NormalizeOutcome(record.Outcome); + var severity = record.Outcome switch + { + AuthEventOutcome.Failure => "warning", + AuthEventOutcome.LockedOut => "warning", + AuthEventOutcome.Error => "error", + _ => "info" + }; + + var subjectId = record.Subject?.SubjectId.Value ?? record.Subject?.Username.Value ?? "authority-system"; + var subjectName = record.Subject?.DisplayName.Value ?? record.Subject?.Username.Value ?? subjectId; + + var details = new Dictionary(OrdinalComparer) + { + ["outcome"] = outcome, + ["eventType"] = record.EventType + }; + if (!string.IsNullOrWhiteSpace(record.Reason)) + { + details["reason"] = record.Reason; + } + if (record.Client is { } client) + { + details["client.id"] = client.ClientId.Value; + details["client.provider"] = client.Provider.Value; + } + + return new AuditEventPayload + { + Id = $"authority-{Guid.NewGuid():N}", + Timestamp = record.OccurredAt, + Module = "authority", + Action = record.EventType ?? "unknown", + Severity = severity, + Actor = new AuditActorPayload + { + Id = subjectId, + Name = subjectName, + Type = "user", + IpAddress = record.Network?.RemoteAddress.Value ?? record.Network?.ForwardedFor.Value, + UserAgent = record.Network?.UserAgent.Value + }, + Resource = new AuditResourcePayload + { + Type = "authority_session", + Id = record.CorrelationId ?? subjectId, + Name = subjectName + }, + Description = $"Authority {record.EventType} ({outcome})", + Details = details, + CorrelationId = record.CorrelationId, + TenantId = record.Tenant.HasValue ? record.Tenant.Value : null, + Tags = new[] { "authority", outcome } + }; } private static AuthorityLoginAttemptDocument MapToDocument(AuthEventRecord record)