Initial commit
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
using System.Data;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core SaveChanges interceptor that writes an AuditLog row for every
|
||||
/// meaningful create / update / soft-delete on tracked entities.
|
||||
/// Writes via raw SQL so it never re-triggers itself.
|
||||
/// </summary>
|
||||
public class AuditInterceptor : SaveChangesInterceptor
|
||||
{
|
||||
// Entity types we care about auditing
|
||||
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
||||
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
|
||||
nameof(InventoryItem), nameof(Company),
|
||||
// Financial entities
|
||||
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
||||
nameof(Deposit), nameof(CreditMemo), nameof(GiftCertificate),
|
||||
nameof(Expense), nameof(PurchaseOrder),
|
||||
// Operations
|
||||
nameof(Appointment), nameof(InventoryTransaction), nameof(JobTemplate),
|
||||
// User management
|
||||
nameof(ApplicationUser)
|
||||
};
|
||||
|
||||
// Properties to always exclude from change capture (noisy / binary)
|
||||
private static readonly HashSet<string> ExcludedProperties = new(StringComparer.Ordinal)
|
||||
{
|
||||
"LogoData", "ProfilePictureData", "Settings", "UpdatedAt", "CreatedAt"
|
||||
};
|
||||
|
||||
// Per-context pending audit entries (ConditionalWeakTable gives us automatic cleanup)
|
||||
private readonly ConditionalWeakTable<DbContext, List<AuditLog>> _pending = new();
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ILogger<AuditInterceptor> _logger;
|
||||
|
||||
public AuditInterceptor(IHttpContextAccessor httpContextAccessor, ILogger<AuditInterceptor> logger)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── Collect entries BEFORE save (so we can capture OriginalValues) ──────────
|
||||
|
||||
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
||||
{
|
||||
Collect(eventData.Context);
|
||||
return base.SavingChanges(eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Collect(eventData.Context);
|
||||
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
// ── Write entries AFTER save (entity IDs are populated now) ─────────────────
|
||||
|
||||
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
|
||||
{
|
||||
FlushSync(eventData.Context);
|
||||
return base.SavedChanges(eventData, result);
|
||||
}
|
||||
|
||||
public override async ValueTask<int> SavedChangesAsync(
|
||||
SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await FlushAsync(eventData.Context, cancellationToken);
|
||||
return await base.SavedChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private void Collect(DbContext? context)
|
||||
{
|
||||
if (context == null) return;
|
||||
var entries = BuildEntries(context);
|
||||
if (entries.Count == 0) return;
|
||||
|
||||
// Remove any stale entries left by a previous failed SaveChanges on this context.
|
||||
// Without this, EF Core's EnableRetryOnFailure would trigger a second SavingChanges
|
||||
// on the same DbContext instance, causing _pending.Add to throw ArgumentException.
|
||||
_pending.Remove(context);
|
||||
_pending.Add(context, entries);
|
||||
}
|
||||
|
||||
private void FlushSync(DbContext? context)
|
||||
{
|
||||
if (context == null || !_pending.TryGetValue(context, out var entries)) return;
|
||||
_pending.Remove(context);
|
||||
foreach (var log in entries)
|
||||
{
|
||||
try { InsertSync(context, log); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "Failed to write audit log entry for {Action} on {EntityType} {EntityId}", log.Action, log.EntityType, log.EntityId); }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FlushAsync(DbContext? context, CancellationToken ct)
|
||||
{
|
||||
if (context == null || !_pending.TryGetValue(context, out var entries)) return;
|
||||
_pending.Remove(context);
|
||||
foreach (var log in entries)
|
||||
{
|
||||
try { await InsertAsync(context, log, ct); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "Failed to write audit log entry for {Action} on {EntityType} {EntityId}", log.Action, log.EntityType, log.EntityId); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build AuditLog objects from change tracker ───────────────────────────────
|
||||
|
||||
private List<AuditLog> BuildEntries(DbContext context)
|
||||
{
|
||||
var logs = new List<AuditLog>();
|
||||
var (userId, userName, companyId, ip) = GetRequestContext();
|
||||
|
||||
foreach (var entry in context.ChangeTracker.Entries())
|
||||
{
|
||||
var typeName = entry.Entity.GetType().Name;
|
||||
if (!AuditedTypes.Contains(typeName)) continue;
|
||||
|
||||
string action;
|
||||
string? oldJson = null;
|
||||
string? newJson = null;
|
||||
|
||||
switch (entry.State)
|
||||
{
|
||||
case EntityState.Added:
|
||||
action = "Created";
|
||||
newJson = SerializeProperties(entry.CurrentValues, null);
|
||||
break;
|
||||
|
||||
case EntityState.Modified:
|
||||
// Detect soft-delete vs plain update
|
||||
var isDeletedProp = entry.Properties.FirstOrDefault(p => p.Metadata.Name == "IsDeleted");
|
||||
if (isDeletedProp != null &&
|
||||
isDeletedProp.CurrentValue is true &&
|
||||
isDeletedProp.OriginalValue is false)
|
||||
{
|
||||
action = "Deleted";
|
||||
}
|
||||
else if (isDeletedProp != null &&
|
||||
isDeletedProp.CurrentValue is false &&
|
||||
isDeletedProp.OriginalValue is true)
|
||||
{
|
||||
action = "Restored";
|
||||
}
|
||||
else
|
||||
{
|
||||
action = "Updated";
|
||||
}
|
||||
oldJson = SerializeProperties(entry.OriginalValues, entry.CurrentValues);
|
||||
newJson = SerializeProperties(entry.CurrentValues, entry.OriginalValues);
|
||||
break;
|
||||
|
||||
case EntityState.Deleted:
|
||||
action = "Deleted";
|
||||
oldJson = SerializeProperties(entry.OriginalValues, null);
|
||||
break;
|
||||
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Entity ID — try "Id" property
|
||||
var idProp = entry.Properties.FirstOrDefault(p => p.Metadata.Name == "Id");
|
||||
var entityId = idProp?.CurrentValue?.ToString();
|
||||
|
||||
// Entity description — try common naming properties
|
||||
var description = GetDescription(entry);
|
||||
|
||||
// Company ID on the entity itself
|
||||
var entityCompanyProp = entry.Properties.FirstOrDefault(p => p.Metadata.Name == "CompanyId");
|
||||
var entityCompanyId = entityCompanyProp?.CurrentValue as int? ?? companyId;
|
||||
|
||||
logs.Add(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
UserName = userName,
|
||||
CompanyId = entityCompanyId,
|
||||
Action = action,
|
||||
EntityType = typeName,
|
||||
EntityId = entityId,
|
||||
EntityDescription = description,
|
||||
OldValues = oldJson,
|
||||
NewValues = newJson,
|
||||
IpAddress = ip,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
private static string? SerializeProperties(Microsoft.EntityFrameworkCore.ChangeTracking.PropertyValues values,
|
||||
Microsoft.EntityFrameworkCore.ChangeTracking.PropertyValues? compareAgainst)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var prop in values.Properties)
|
||||
{
|
||||
if (ExcludedProperties.Contains(prop.Name)) continue;
|
||||
var current = values[prop];
|
||||
|
||||
// When comparing, only include changed properties
|
||||
if (compareAgainst != null)
|
||||
{
|
||||
var other = compareAgainst[prop];
|
||||
if (Equals(current, other)) continue;
|
||||
}
|
||||
|
||||
dict[prop.Name] = current;
|
||||
}
|
||||
return dict.Count == 0 ? null : JsonSerializer.Serialize(dict);
|
||||
}
|
||||
|
||||
private static string? GetDescription(Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry)
|
||||
{
|
||||
// Try common "name" properties in priority order
|
||||
string[] candidates = ["JobNumber", "QuoteNumber", "InvoiceNumber", "BillNumber", "PaymentNumber",
|
||||
"ReceiptNumber", "CertificateNumber", "Email", "CompanyName", "Name", "EquipmentName", "ItemName"];
|
||||
foreach (var name in candidates)
|
||||
{
|
||||
var prop = entry.Properties.FirstOrDefault(p => p.Metadata.Name == name);
|
||||
if (prop?.CurrentValue is string s && !string.IsNullOrWhiteSpace(s))
|
||||
return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Raw SQL insert (bypasses change tracker → no recursion) ─────────────────
|
||||
|
||||
private static void InsertSync(DbContext context, AuditLog log)
|
||||
{
|
||||
context.Database.ExecuteSqlRaw(InsertSql, BuildParams(log));
|
||||
}
|
||||
|
||||
private static async Task InsertAsync(DbContext context, AuditLog log, CancellationToken ct)
|
||||
{
|
||||
await context.Database.ExecuteSqlRawAsync(InsertSql, BuildParams(log), ct);
|
||||
}
|
||||
|
||||
private static SqlParameter[] BuildParams(AuditLog log) =>
|
||||
[
|
||||
new SqlParameter("@userId", SqlDbType.NVarChar, -1) { Value = (object?)log.UserId ?? DBNull.Value },
|
||||
new SqlParameter("@userName", SqlDbType.NVarChar, -1) { Value = (object)log.UserName },
|
||||
new SqlParameter("@companyId", SqlDbType.Int) { Value = (object?)log.CompanyId ?? DBNull.Value },
|
||||
new SqlParameter("@companyName", SqlDbType.NVarChar, -1) { Value = (object?)log.CompanyName ?? DBNull.Value },
|
||||
new SqlParameter("@action", SqlDbType.NVarChar, -1) { Value = (object)log.Action },
|
||||
new SqlParameter("@entityType", SqlDbType.NVarChar, -1) { Value = (object)log.EntityType },
|
||||
new SqlParameter("@entityId", SqlDbType.NVarChar, -1) { Value = (object?)log.EntityId ?? DBNull.Value },
|
||||
new SqlParameter("@entityDescription", SqlDbType.NVarChar, -1) { Value = (object?)log.EntityDescription ?? DBNull.Value },
|
||||
new SqlParameter("@oldValues", SqlDbType.NVarChar, -1) { Value = (object?)log.OldValues ?? DBNull.Value },
|
||||
new SqlParameter("@newValues", SqlDbType.NVarChar, -1) { Value = (object?)log.NewValues ?? DBNull.Value },
|
||||
new SqlParameter("@ipAddress", SqlDbType.NVarChar, -1) { Value = (object?)log.IpAddress ?? DBNull.Value },
|
||||
new SqlParameter("@timestamp", SqlDbType.DateTime2) { Value = (object)log.Timestamp },
|
||||
];
|
||||
|
||||
private const string InsertSql = """
|
||||
INSERT INTO AuditLogs
|
||||
(UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
|
||||
EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
|
||||
VALUES (@userId, @userName, @companyId, @companyName, @action, @entityType, @entityId,
|
||||
@entityDescription, @oldValues, @newValues, @ipAddress, @timestamp)
|
||||
""";
|
||||
|
||||
// ── Request context helpers ───────────────────────────────────────────────────
|
||||
|
||||
private (string? userId, string userName, int? companyId, string? ip) GetRequestContext()
|
||||
{
|
||||
var ctx = _httpContextAccessor.HttpContext;
|
||||
if (ctx == null) return (null, "System", null, null);
|
||||
|
||||
var user = ctx.User;
|
||||
var userId = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
var userName = user.Identity?.Name ?? user.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "Unknown";
|
||||
var companyIdStr = user.FindFirst("CompanyId")?.Value;
|
||||
int? companyId = companyIdStr != null && int.TryParse(companyIdStr, out var cid) ? cid : null;
|
||||
var ip = ctx.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
return (userId, userName, companyId, ip);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user