Add audit logs.
ci-cd-vm / ci-cd (push) Successful in 4m2s

This commit is contained in:
Chris Chen
2026-06-23 12:13:47 -07:00
parent 870eeec82a
commit 62592c29ae
106 changed files with 2522 additions and 311 deletions
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors;
/// <summary>
/// Writes a before→after <see cref="AuditLog"/> row for every Create/Update/Delete of an
/// <see cref="IAuditable"/> entity. Two-phase: snapshot changed values BEFORE save (while
/// original values are still available), then — AFTER save succeeds — read DB-generated keys and
/// enqueue the rows. Enqueuing (rather than inserting here) avoids a second SaveChanges, can't
/// fail the user's transaction, and never recurses through AppDbContext.
/// </summary>
public sealed class AuditLogInterceptor : SaveChangesInterceptor
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
private readonly List<PendingAudit> _pending = [];
public AuditLogInterceptor(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
Capture(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Capture(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
Flush();
return base.SavedChanges(eventData, result);
}
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
Flush();
return base.SavedChangesAsync(eventData, result, cancellationToken);
}
public override void SaveChangesFailed(DbContextErrorEventData eventData) => _pending.Clear();
public override Task SaveChangesFailedAsync(
DbContextErrorEventData eventData, CancellationToken cancellationToken = default)
{
_pending.Clear();
return Task.CompletedTask;
}
// ── Phase 1: snapshot before save ─────────────────────────────────────────
private void Capture(DbContext? db)
{
if (db is null)
return;
foreach (var entry in db.ChangeTracker.Entries())
{
if (entry.Entity is not IAuditable)
continue;
switch (entry.State)
{
case EntityState.Added:
_pending.Add(new PendingAudit(entry, AuditActions.Create, null, BuildValues(entry, current: true)));
break;
case EntityState.Deleted:
_pending.Add(new PendingAudit(entry, AuditActions.Delete, BuildValues(entry, current: false), null));
break;
case EntityState.Modified:
var before = new Dictionary<string, object?>();
var after = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (!property.IsModified)
continue;
var name = property.Metadata.Name;
before[name] = Read(name, property.OriginalValue);
after[name] = Read(name, property.CurrentValue);
}
if (after.Count == 0)
break; // no real change (e.g. only audit timestamps touched on a no-op)
// A soft-delete (IsDeleted false→true) reads more naturally as a Delete.
var action = IsSoftDelete(after) ? AuditActions.Delete : AuditActions.Update;
_pending.Add(new PendingAudit(entry, action, before, after));
break;
}
}
}
// ── Phase 2: keys exist, enqueue ──────────────────────────────────────────
private void Flush()
{
if (_pending.Count == 0)
return;
var userId = _currentUser.UserId;
var userEmail = _currentUser.Email;
var ip = _currentUser.IpAddress;
var corr = _currentUser.CorrelationId;
foreach (var item in _pending)
{
_queue.TryEnqueue(new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelEnum.Information,
Action = item.Action,
Category = AuditCategories.DataChange,
EntityName = item.Entry.Metadata.ClrType.Name,
EntityId = ReadKey(item.Entry),
Changes = AuditChangeSerializer.BuildChanges(item.Before, item.After),
UserId = userId,
UserEmail = userEmail,
IpAddress = ip,
CorrelationId = corr,
});
}
_pending.Clear();
}
private static Dictionary<string, object?> BuildValues(EntityEntry entry, bool current)
{
var values = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (property.Metadata.IsPrimaryKey())
continue;
var name = property.Metadata.Name;
values[name] = Read(name, current ? property.CurrentValue : property.OriginalValue);
}
return values;
}
private static object? Read(string propertyName, object? value) =>
AuditChangeSerializer.IsSensitive(propertyName) ? AuditChangeSerializer.MaskValue : value;
private static bool IsSoftDelete(Dictionary<string, object?> after) =>
after.TryGetValue("IsDeleted", out var value) && value is true;
private static string? ReadKey(EntityEntry entry)
{
var key = entry.Metadata.FindPrimaryKey();
if (key is null)
return null;
var parts = key.Properties
.Select(p => entry.Property(p.Name).CurrentValue?.ToString())
.Where(v => v is not null);
return string.Join(",", parts);
}
private sealed record PendingAudit(
EntityEntry Entry,
string Action,
Dictionary<string, object?>? Before,
Dictionary<string, object?>? After);
}
@@ -1,15 +1,15 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors;
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
private readonly IHttpContextAccessor _http;
private readonly CurrentUserAccessor _currentUser;
public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http;
public AuditSaveChangesInterceptor(CurrentUserAccessor currentUser) => _currentUser = currentUser;
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
@@ -30,8 +30,7 @@ public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
if (db is null) return;
var userId = _http.HttpContext?.User
.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
var userId = _currentUser.UserIdOrSystem;
var now = DateTimeOffset.UtcNow;
foreach (var entry in db.ChangeTracker.Entries())