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);
}
}
}