Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

120 строки
4.4KB

  1. using System.Collections.Concurrent;
  2. using System.Text.Json;
  3. namespace Campaign_Tracker.Server.Audit;
  4. /// <summary>
  5. /// Persistent, append-only audit service that writes JSON Lines to daily rotating files.
  6. ///
  7. /// Guarantees:
  8. /// - AC #1: Every Record() call writes actor, timestamp (UTC), event type, resource, outcome.
  9. /// - AC #2: Files are never deleted by this service; retained indefinitely (365+ day policy).
  10. /// - AC #4: The interface and file format are append-only — no update or delete operations exist.
  11. /// - AC #5: Record() throws AuditServiceUnavailableException if the file system is unavailable,
  12. /// which propagates to the caller and blocks the auditable action.
  13. /// </summary>
  14. public sealed class AppendOnlyFileAuditService : IAuditService
  15. {
  16. private readonly string _logDirectory;
  17. private readonly ILogger<AppendOnlyFileAuditService> _logger;
  18. private readonly ConcurrentQueue<AuditEvent> _recentEvents = new();
  19. private readonly object _fileLock = new();
  20. private const int MaxRecentEvents = 1000;
  21. private static readonly JsonSerializerOptions JsonOptions = new()
  22. {
  23. PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
  24. };
  25. public AppendOnlyFileAuditService(string logDirectory, ILogger<AppendOnlyFileAuditService> logger)
  26. {
  27. _logDirectory = logDirectory;
  28. _logger = logger;
  29. Directory.CreateDirectory(logDirectory);
  30. }
  31. /// <summary>
  32. /// Appends the event as a JSON line to the day's audit file, then caches it in memory.
  33. /// Throws <see cref="AuditServiceUnavailableException"/> if the write fails (AC #5).
  34. /// </summary>
  35. public void Record(AuditEvent auditEvent)
  36. {
  37. ValidateRequiredFields(auditEvent);
  38. var normalizedEvent = auditEvent with
  39. {
  40. RecordedAt = auditEvent.RecordedAt.ToUniversalTime(),
  41. };
  42. var line = JsonSerializer.Serialize(normalizedEvent, JsonOptions) + Environment.NewLine;
  43. var filePath = GetDailyFilePath(normalizedEvent.RecordedAt);
  44. try
  45. {
  46. lock (_fileLock)
  47. {
  48. File.AppendAllText(filePath, line);
  49. }
  50. }
  51. catch (Exception ex)
  52. {
  53. _logger.LogError(ex, "Audit write failed for event {EventType} by {Actor}.", auditEvent.EventType, auditEvent.ActorIdentity);
  54. throw new AuditServiceUnavailableException(
  55. $"Audit log write failed for {auditEvent.EventType}.", ex);
  56. }
  57. // Maintain bounded in-memory cache for GetRecent() queries.
  58. _recentEvents.Enqueue(normalizedEvent);
  59. while (_recentEvents.Count > MaxRecentEvents)
  60. {
  61. _recentEvents.TryDequeue(out _);
  62. }
  63. }
  64. /// <summary>
  65. /// Returns up to <paramref name="maxCount"/> of the most recently recorded events
  66. /// from the in-process cache. For full historical queries, read the .jsonl files directly.
  67. /// </summary>
  68. public IReadOnlyCollection<AuditEvent> GetRecent(int maxCount = 200)
  69. {
  70. if (maxCount < 0)
  71. {
  72. throw new ArgumentOutOfRangeException(nameof(maxCount), "Recent audit event count cannot be negative.");
  73. }
  74. if (maxCount == 0)
  75. {
  76. return [];
  77. }
  78. var events = _recentEvents.ToArray();
  79. return events.Length <= maxCount
  80. ? events
  81. : events[^maxCount..];
  82. }
  83. /// <summary>Returns the path of the log file for the given UTC date.</summary>
  84. public string GetDailyFilePath(DateTimeOffset timestamp)
  85. {
  86. var date = timestamp.UtcDateTime.ToString("yyyy-MM-dd");
  87. return Path.Combine(_logDirectory, $"audit-{date}.jsonl");
  88. }
  89. private static void ValidateRequiredFields(AuditEvent auditEvent)
  90. {
  91. ArgumentNullException.ThrowIfNull(auditEvent);
  92. ValidateRequired(auditEvent.EventType, nameof(auditEvent.EventType));
  93. ValidateRequired(auditEvent.ActorIdentity, nameof(auditEvent.ActorIdentity));
  94. ValidateRequired(auditEvent.Resource, nameof(auditEvent.Resource));
  95. ValidateRequired(auditEvent.Outcome, nameof(auditEvent.Outcome));
  96. ValidateRequired(auditEvent.TraceIdentifier, nameof(auditEvent.TraceIdentifier));
  97. }
  98. private static void ValidateRequired(string? value, string fieldName)
  99. {
  100. if (string.IsNullOrWhiteSpace(value))
  101. {
  102. throw new ArgumentException($"Audit field {fieldName} is required.", fieldName);
  103. }
  104. }
  105. }

Powered by TurnKey Linux.