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)