|
- using System.Collections.Concurrent;
- using System.Text.Json;
-
- namespace Campaign_Tracker.Server.Audit;
-
- /// <summary>
- /// 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.
- /// </summary>
- public sealed class AppendOnlyFileAuditService : IAuditService
- {
- private readonly string _logDirectory;
- private readonly ILogger<AppendOnlyFileAuditService> _logger;
- private readonly ConcurrentQueue<AuditEvent> _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<AppendOnlyFileAuditService> logger)
- {
- _logDirectory = logDirectory;
- _logger = logger;
- Directory.CreateDirectory(logDirectory);
- }
-
- /// <summary>
- /// Appends the event as a JSON line to the day's audit file, then caches it in memory.
- /// Throws <see cref="AuditServiceUnavailableException"/> if the write fails (AC #5).
- /// </summary>
- 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 _);
- }
- }
-
- /// <summary>
- /// Returns up to <paramref name="maxCount"/> of the most recently recorded events
- /// from the in-process cache. For full historical queries, read the .jsonl files directly.
- /// </summary>
- public IReadOnlyCollection<AuditEvent> 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..];
- }
-
- /// <summary>Returns the path of the log file for the given UTC date.</summary>
- 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);
- }
- }
- }
|