feat(authority): dual-write audit events to Timeline unified sink
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<AuthorityAuditSink> logger;
|
||||
private readonly IAuditEventEmitter? timelineEmitter;
|
||||
|
||||
public AuthorityAuditSink(
|
||||
IAuthorityLoginAttemptStore loginAttemptStore,
|
||||
ILogger<AuthorityAuditSink> logger)
|
||||
ILogger<AuthorityAuditSink> 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<string, object?>(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)
|
||||
|
||||
Reference in New Issue
Block a user