using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Options; namespace FinalFactory.Rendezvous.Server.Observability; internal sealed class AuditTrail { private readonly object _gate = new(); private readonly LinkedList _entries = []; private readonly AuditOptions _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly RendezvousTelemetry _telemetry; public AuditTrail( IOptions options, ILogger logger, RendezvousTelemetry telemetry, TimeProvider? timeProvider = null) { _options = options.Value; _logger = logger; _telemetry = telemetry; _timeProvider = timeProvider ?? TimeProvider.System; } public void Record( string actorSubject, string action, string result, string targetKind, string targetIdentifier, string correlationId) { DateTimeOffset now = _timeProvider.GetUtcNow(); AuditEntry entry = new( now, Fingerprint(actorSubject), action, result, targetKind, Fingerprint(targetIdentifier), correlationId); lock (_gate) { PurgeExpired(now); while (_entries.Count >= _options.MaxEntries) { _entries.RemoveFirst(); } _entries.AddLast(entry); } _telemetry.RecordAudit(action, result); LogOperatorAction( _logger, entry.ActorFingerprint, action, result, targetKind, entry.TargetFingerprint, correlationId); } public IReadOnlyDictionary GetAggregateCounts() { lock (_gate) { PurgeExpired(_timeProvider.GetUtcNow()); return _entries .GroupBy(static entry => $"{entry.Action}:{entry.Result}", StringComparer.Ordinal) .ToDictionary( static group => group.Key, static group => (long)group.Count(), StringComparer.Ordinal); } } internal IReadOnlyList GetEntriesForTests() { lock (_gate) { PurgeExpired(_timeProvider.GetUtcNow()); return _entries.ToArray(); } } private void PurgeExpired(DateTimeOffset now) { DateTimeOffset oldest = now.AddDays(-_options.RetentionDays); while (_entries.First is { Value.Timestamp: var timestamp } && timestamp < oldest) { _entries.RemoveFirst(); } } private static string Fingerprint(string value) { byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(value)); return Convert.ToHexString(digest.AsSpan(0, 12)); } private static readonly Action OperatorAction = LoggerMessage.Define( LogLevel.Information, new EventId(100, nameof(LogOperatorAction)), "Operator audit: actor {ActorFingerprint} action {Action} completed with {Result} for {TargetKind} target {TargetFingerprint}; correlation {CorrelationId}"); private static void LogOperatorAction( ILogger logger, string actorFingerprint, string action, string result, string targetKind, string targetFingerprint, string correlationId) => OperatorAction( logger, actorFingerprint, action, result, targetKind, targetFingerprint, correlationId, null); } internal sealed record AuditEntry( DateTimeOffset Timestamp, string ActorFingerprint, string Action, string Result, string TargetKind, string TargetFingerprint, string CorrelationId) { public override string ToString() => $"[AuditEntry {Action}/{Result}; actor and target fingerprinted]"; }