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:
master
2026-04-19 22:35:47 +03:00
parent 05462f0443
commit a947c8df6e

View File

@@ -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)