@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data.Logging;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Data;
|
||||
@@ -324,5 +325,12 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
||||
});
|
||||
|
||||
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
|
||||
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
|
||||
// context, so business code can't write logs through the audited context.
|
||||
// Runtime reads/writes go through the dedicated LogDbContext. Including
|
||||
// them in the model lets the single startup migration create the tables.
|
||||
LogModelConfiguration.Configure(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ public static class DbSeeder
|
||||
("finance", Modules.MonthlyStatements, true, true, false, true),
|
||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||
("finance", Modules.Disbursements, true, true, true, true),
|
||||
|
||||
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
||||
// governance value, so finance and board members can read them too.
|
||||
("pastor", Modules.SystemLogs, true, false, false, false),
|
||||
("pastor", Modules.AuditLogs, true, false, false, false),
|
||||
("finance", Modules.AuditLogs, true, false, false, false),
|
||||
("board_member", Modules.AuditLogs, true, false, false, false),
|
||||
];
|
||||
|
||||
public static async Task SeedRolePermissionsAsync(AppDbContext db)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
|
||||
namespace ROLAC.API.Data.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// A minimal, write-mostly context dedicated to the SystemLog / AuditLog tables. It is the
|
||||
/// structural break that prevents log-storms: it is registered WITHOUT the audit interceptors
|
||||
/// and with a silent logger factory (see Program.cs), so persisting a log row produces no log
|
||||
/// events that the DB sink would pick up. It shares the same physical database/connection as
|
||||
/// AppDbContext, but the tables themselves are created by AppDbContext's migration — they are
|
||||
/// only mapped here so this context can read/write them.
|
||||
/// </summary>
|
||||
public class LogDbContext : DbContext
|
||||
{
|
||||
public LogDbContext(DbContextOptions<LogDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<SystemLog> SystemLogs => Set<SystemLog>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
LogModelConfiguration.Configure(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
|
||||
namespace ROLAC.API.Data.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for the SystemLog / AuditLog table schema. Applied by
|
||||
/// <see cref="AppDbContext"/> (so the startup migration creates the tables) AND by
|
||||
/// <see cref="LogDbContext"/> (so runtime reads/writes map to the same shape).
|
||||
/// </summary>
|
||||
public static class LogModelConfiguration
|
||||
{
|
||||
public static void Configure(ModelBuilder builder)
|
||||
{
|
||||
builder.Entity<SystemLog>(entity =>
|
||||
{
|
||||
entity.ToTable("SystemLogs");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Level).HasConversion<byte>();
|
||||
entity.Property(e => e.Category).HasMaxLength(256).IsRequired();
|
||||
entity.Property(e => e.Message).IsRequired(); // text
|
||||
entity.Property(e => e.RequestPath).HasMaxLength(2048);
|
||||
entity.Property(e => e.HttpMethod).HasMaxLength(10);
|
||||
entity.Property(e => e.UserId).HasMaxLength(450);
|
||||
entity.Property(e => e.IpAddress).HasMaxLength(45);
|
||||
entity.Property(e => e.CorrelationId).HasMaxLength(64);
|
||||
|
||||
entity.HasIndex(e => e.Timestamp);
|
||||
entity.HasIndex(e => e.Level);
|
||||
entity.HasIndex(e => new { e.Timestamp, e.Level });
|
||||
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
|
||||
});
|
||||
|
||||
builder.Entity<AuditLog>(entity =>
|
||||
{
|
||||
entity.ToTable("AuditLogs");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Level).HasConversion<byte>();
|
||||
entity.Property(e => e.Action).HasMaxLength(40).IsRequired();
|
||||
entity.Property(e => e.Category).HasMaxLength(40).IsRequired();
|
||||
entity.Property(e => e.EntityName).HasMaxLength(128);
|
||||
entity.Property(e => e.EntityId).HasMaxLength(64);
|
||||
entity.Property(e => e.Changes).HasColumnType("jsonb");
|
||||
entity.Property(e => e.Summary).HasMaxLength(512);
|
||||
entity.Property(e => e.UserId).HasMaxLength(450);
|
||||
entity.Property(e => e.UserEmail).HasMaxLength(256);
|
||||
entity.Property(e => e.IpAddress).HasMaxLength(45);
|
||||
entity.Property(e => e.CorrelationId).HasMaxLength(64);
|
||||
|
||||
entity.HasIndex(e => e.Timestamp);
|
||||
entity.HasIndex(e => new { e.Category, e.Timestamp });
|
||||
entity.HasIndex(e => new { e.EntityName, e.EntityId });
|
||||
entity.HasIndex(e => e.Action);
|
||||
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user