using System.Collections.Concurrent; using System.Text.Json; namespace Campaign_Tracker.Server.Audit; /// /// Persistent, append-only audit service that writes JSON Lines to daily rotating files. /// /// Guarantees: /// - AC #1: Every Record() call writes actor, timestamp (UTC), event type, resource, outcome. /// - AC #2: Files are never deleted by this service; retained indefinitely (365+ day policy). /// - AC #4: The interface and file format are append-only — no update or delete operations exist. /// - AC #5: Record() throws AuditServiceUnavailableException if the file system is unavailable, /// which propagates to the caller and blocks the auditable action. /// public sealed class AppendOnlyFileAuditService : IAuditService { private readonly string _logDirectory; private readonly ILogger _logger; private readonly ConcurrentQueue _recentEvents = new(); private readonly object _fileLock = new(); private const int MaxRecentEvents = 1000; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; public AppendOnlyFileAuditService(string logDirectory, ILogger logger) { _logDirectory = logDirectory; _logger = logger; Directory.CreateDirectory(logDirectory); } /// /// Appends the event as a JSON line to the day's audit file, then caches it in memory. /// Throws if the write fails (AC #5). /// public void Record(AuditEvent auditEvent) { ValidateRequiredFields(auditEvent); var normalizedEvent = auditEvent with { RecordedAt = auditEvent.RecordedAt.ToUniversalTime(), }; var line = JsonSerializer.Serialize(normalizedEvent, JsonOptions) + Environment.NewLine; var filePath = GetDailyFilePath(normalizedEvent.RecordedAt); try { lock (_fileLock) { File.AppendAllText(filePath, line); } } catch (Exception ex) { _logger.LogError(ex, "Audit write failed for event {EventType} by {Actor}.", auditEvent.EventType, auditEvent.ActorIdentity); throw new AuditServiceUnavailableException( $"Audit log write failed for {auditEvent.EventType}.", ex); } // Maintain bounded in-memory cache for GetRecent() queries. _recentEvents.Enqueue(normalizedEvent); while (_recentEvents.Count > MaxRecentEvents) { _recentEvents.TryDequeue(out _); } } /// /// Returns up to of the most recently recorded events /// from the in-process cache. For full historical queries, read the .jsonl files directly. /// public IReadOnlyCollection GetRecent(int maxCount = 200) { if (maxCount < 0) { throw new ArgumentOutOfRangeException(nameof(maxCount), "Recent audit event count cannot be negative."); } if (maxCount == 0) { return []; } var events = _recentEvents.ToArray(); return events.Length <= maxCount ? events : events[^maxCount..]; } /// Returns the path of the log file for the given UTC date. public string GetDailyFilePath(DateTimeOffset timestamp) { var date = timestamp.UtcDateTime.ToString("yyyy-MM-dd"); return Path.Combine(_logDirectory, $"audit-{date}.jsonl"); } private static void ValidateRequiredFields(AuditEvent auditEvent) { ArgumentNullException.ThrowIfNull(auditEvent); ValidateRequired(auditEvent.EventType, nameof(auditEvent.EventType)); ValidateRequired(auditEvent.ActorIdentity, nameof(auditEvent.ActorIdentity)); ValidateRequired(auditEvent.Resource, nameof(auditEvent.Resource)); ValidateRequired(auditEvent.Outcome, nameof(auditEvent.Outcome)); ValidateRequired(auditEvent.TraceIdentifier, nameof(auditEvent.TraceIdentifier)); } private static void ValidateRequired(string? value, string fieldName) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException($"Audit field {fieldName} is required.", fieldName); } } }