178 lines
6.7 KiB
C#
178 lines
6.7 KiB
C#
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);
|
|
}
|