Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using SW.PrimitiveTypes;

namespace SW.EfCoreExtensions;
Expand Down Expand Up @@ -118,6 +119,34 @@ public sealed class PendingAuditEntry
}


/// <summary>
/// Controls what <see cref="AuditBuilderExtension.CapturePendingAuditDiffs"/> records. Both filters
/// default to <c>null</c>, which captures every changed entity and every one of its properties —
/// the behaviour callers get when they pass no options at all.
/// </summary>
/// <remarks>
/// <see cref="ShouldAuditProperty"/> is what keeps credentials out of an audit log. It is applied
/// before the entity state is considered, so a property excluded here is absent from an
/// <see cref="EntityState.Added"/> snapshot just as it is from a <see cref="EntityState.Modified"/>
/// diff — an exclusion that only covered modifications would still write the secret out in full the
/// first time the row was inserted.
/// </remarks>
public sealed class AuditOptions
{
/// <summary>
/// Decides whether an entity is audited at all. Return <c>false</c> and no entry is produced for
/// it. Use this to keep high-volume runtime tables out of a log meant to record configuration
/// changes, where one entry per row processed would bury the entries worth reading.
/// </summary>
public Func<EntityEntry, bool>? ShouldAuditEntity { get; init; }

/// <summary>
/// Decides whether a single property is captured. Return <c>false</c> and the property appears
/// in no diff, whatever state its entity is in.
/// </summary>
public Func<EntityEntry, IProperty, bool>? ShouldAuditProperty { get; init; }
}

/// <summary>
/// Provides extension methods for building audit trails from Entity Framework Core change tracking.
/// Enables capturing, finalizing, and reconstructing entity changes for audit logging purposes.
Expand All @@ -139,6 +168,7 @@ public static class AuditBuilderExtension
/// <item><description>Generates a shared correlation ID for all changes in this batch</description></item>
/// <item><description>Records a UTC timestamp shared by all changes</description></item>
/// <item><description>Skips entities with no meaningful changes (e.g., only temporary properties changed)</description></item>
/// <item><description>Skips entities and properties excluded by <paramref name="options"/></description></item>
/// <item><description>Captures domain events from entities implementing IGeneratesDomainEvents</description></item>
/// </list>
/// Use <see cref="FinalizeAuditDiffJson"/> to convert the results to JSON-serializable format.
Expand All @@ -152,6 +182,20 @@ public static class AuditBuilderExtension
/// </example>
public static IReadOnlyCollection<PendingAuditEntry>
CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null)
=> changeTracker.CapturePendingAuditDiffs(userId, null);

/// <inheritdoc cref="CapturePendingAuditDiffs(ChangeTracker, string?)"/>
/// <param name="changeTracker">The Entity Framework change tracker to capture changes from.</param>
/// <param name="userId">Optional identifier of the user or actor making the changes.</param>
/// <param name="options">Filters narrowing which entities and properties are captured. Null captures everything.</param>
/// <remarks>
/// A separate overload rather than an optional parameter on the one above: optional arguments
/// are baked in at the call site, so adding one to a published method leaves assemblies already
/// compiled against the old signature unable to bind to it.
/// </remarks>
public static IReadOnlyCollection<PendingAuditEntry>
CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId,
AuditOptions? options)
{
changeTracker.DetectChanges();

Expand All @@ -166,7 +210,10 @@ public static IReadOnlyCollection<PendingAuditEntry>
or EntityState.Modified
or EntityState.Deleted))
{
var changes = BuildDiff(entry);
if (options?.ShouldAuditEntity is not null && !options.ShouldAuditEntity(entry))
continue;

var changes = BuildDiff(entry, options);

if (changes.Count == 0)
continue; // nothing meaningful changed
Expand Down Expand Up @@ -282,7 +329,7 @@ public static IReadOnlyCollection<GenericAuditDiffJson>
return state;
}
private static Dictionary<string, PropertyDiff>
BuildDiff(EntityEntry entry)
BuildDiff(EntityEntry entry, AuditOptions? options)
{
var diffs = new Dictionary<string, PropertyDiff>();

Expand All @@ -291,6 +338,9 @@ private static Dictionary<string, PropertyDiff>
if (prop.IsTemporary)
continue;

if (options?.ShouldAuditProperty is not null && !options.ShouldAuditProperty(entry, prop.Metadata))
continue;

if (entry.State == EntityState.Added)
{
diffs[prop.Metadata.Name] =
Expand Down
142 changes: 142 additions & 0 deletions SW.EfCoreExtensions.UnitTests/AuditTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SW.EfCoreExtensions.UnitTests.Domain;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace SW.EfCoreExtensions.UnitTests
{
[TestClass]
public class AuditTests
{
static TestServer server;

[ClassInitialize]
public static void ClassInitialize(TestContext tcontext)
{
server = new TestServer(WebHost.CreateDefaultBuilder()
.UseDefaultServiceProvider((context, options) => { options.ValidateScopes = true; })
.UseEnvironment("Development")
.UseStartup<TestStartup>());
}

[ClassCleanup]
public static void ClassCleanup()
{
server.Dispose();
}

static Employee NewEmployee() => new Employee
{
UserName = "adam",
Email = "adam@example.com",
FirstName = "Adam",
LastName = "Smith"
};

[TestMethod]
public void CapturesEveryPropertyWhenNoOptionsAreGiven()
{
using var scope = server.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TestDbContext>();
dbContext.Add(NewEmployee());

var changes = SingleEmployeeEntry(dbContext.ChangeTracker.CapturePendingAuditDiffs()).Changes;

Assert.IsTrue(changes.ContainsKey(nameof(Employee.UserName)));
Assert.IsTrue(changes.ContainsKey(nameof(Employee.Email)));
}

/// <summary>
/// The case worth guarding: an Added entity is captured as a full snapshot rather than a
/// diff, so an exclusion that only applied to modifications would still write the secret out
/// in full the very first time the row was inserted.
/// </summary>
[TestMethod]
public void ExcludedPropertyIsAbsentFromAnAddedSnapshot()
{
using var scope = server.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TestDbContext>();
dbContext.Add(NewEmployee());

var changes = SingleEmployeeEntry(
dbContext.ChangeTracker.CapturePendingAuditDiffs(null, ExcludeEmail)).Changes;

Assert.IsFalse(changes.ContainsKey(nameof(Employee.Email)));
Assert.IsTrue(changes.ContainsKey(nameof(Employee.UserName)));
}

[TestMethod]
async public Task ExcludedPropertyIsAbsentFromAModifiedDiff()
{
using var scope = server.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TestDbContext>();

var employee = NewEmployee();
dbContext.Add(employee);
await dbContext.SaveChangesAsync();

employee.Email = "adam@changed.com";
employee.LastName = "Jones";

var changes = SingleEmployeeEntry(
dbContext.ChangeTracker.CapturePendingAuditDiffs(null, ExcludeEmail)).Changes;

Assert.IsFalse(changes.ContainsKey(nameof(Employee.Email)));
Assert.AreEqual("Jones", changes[nameof(Employee.LastName)].New);
Assert.AreEqual("Smith", changes[nameof(Employee.LastName)].Old);
}

[TestMethod]
public void EntityFilterSkipsUnauditedEntitiesEntirely()
{
using var scope = server.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TestDbContext>();
dbContext.Add(NewEmployee());
dbContext.Add(new SomeData { StringArray = new[] { "x" } });

var captured = dbContext.ChangeTracker.CapturePendingAuditDiffs(null, new AuditOptions
{
ShouldAuditEntity = entry => entry.Entity is SomeData
});

Assert.IsFalse(captured.Any(c => c.EntityType == typeof(Employee).FullName));
Assert.IsTrue(captured.Any(c => c.EntityType == typeof(SomeData).FullName));
}

[TestMethod]
async public Task FinalizeResolvesTheGeneratedPrimaryKey()
{
using var scope = server.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TestDbContext>();

var employee = NewEmployee();
dbContext.Add(employee);

var pending = dbContext.ChangeTracker.CapturePendingAuditDiffs(userId: "user-42");
await dbContext.SaveChangesAsync();
var finalized = pending.FinalizeAuditDiffJson()
.Single(f => f.EntityType == typeof(Employee).FullName);

var primaryKey = (IDictionary<string, object>)finalized.PrimaryKey;

Assert.AreEqual(employee.Id, primaryKey[nameof(Employee.Id)]);
Assert.AreNotEqual(0, employee.Id);
Assert.AreEqual("user-42", finalized.UserId);
Assert.AreEqual("Added", finalized.State);
}

static readonly AuditOptions ExcludeEmail = new()
{
ShouldAuditProperty = (entry, property) => property.Name != nameof(Employee.Email)
};

static PendingAuditEntry SingleEmployeeEntry(IReadOnlyCollection<PendingAuditEntry> captured) =>
captured.Single(c => c.EntityType == typeof(Employee).FullName);
}
}
54 changes: 52 additions & 2 deletions SW.EfCoreExtensions/AuditBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using SW.PrimitiveTypes;

namespace SW.EfCoreExtensions;
Expand Down Expand Up @@ -118,6 +119,34 @@ public sealed class PendingAuditEntry
}


/// <summary>
/// Controls what <see cref="AuditBuilderExtension.CapturePendingAuditDiffs"/> records. Both filters
/// default to <c>null</c>, which captures every changed entity and every one of its properties —
/// the behaviour callers get when they pass no options at all.
/// </summary>
/// <remarks>
/// <see cref="ShouldAuditProperty"/> is what keeps credentials out of an audit log. It is applied
/// before the entity state is considered, so a property excluded here is absent from an
/// <see cref="EntityState.Added"/> snapshot just as it is from a <see cref="EntityState.Modified"/>
/// diff — an exclusion that only covered modifications would still write the secret out in full the
/// first time the row was inserted.
/// </remarks>
public sealed class AuditOptions
{
/// <summary>
/// Decides whether an entity is audited at all. Return <c>false</c> and no entry is produced for
/// it. Use this to keep high-volume runtime tables out of a log meant to record configuration
/// changes, where one entry per row processed would bury the entries worth reading.
/// </summary>
public Func<EntityEntry, bool>? ShouldAuditEntity { get; init; }

/// <summary>
/// Decides whether a single property is captured. Return <c>false</c> and the property appears
/// in no diff, whatever state its entity is in.
/// </summary>
public Func<EntityEntry, IProperty, bool>? ShouldAuditProperty { get; init; }
}

/// <summary>
/// Provides extension methods for building audit trails from Entity Framework Core change tracking.
/// Enables capturing, finalizing, and reconstructing entity changes for audit logging purposes.
Expand All @@ -139,6 +168,7 @@ public static class AuditBuilderExtension
/// <item><description>Generates a shared correlation ID for all changes in this batch</description></item>
/// <item><description>Records a UTC timestamp shared by all changes</description></item>
/// <item><description>Skips entities with no meaningful changes (e.g., only temporary properties changed)</description></item>
/// <item><description>Skips entities and properties excluded by <paramref name="options"/></description></item>
/// <item><description>Captures domain events from entities implementing IGeneratesDomainEvents</description></item>
/// </list>
/// Use <see cref="FinalizeAuditDiffJson"/> to convert the results to JSON-serializable format.
Expand All @@ -152,6 +182,20 @@ public static class AuditBuilderExtension
/// </example>
public static IReadOnlyCollection<PendingAuditEntry>
CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null)
=> changeTracker.CapturePendingAuditDiffs(userId, null);

/// <inheritdoc cref="CapturePendingAuditDiffs(ChangeTracker, string?)"/>
/// <param name="changeTracker">The Entity Framework change tracker to capture changes from.</param>
/// <param name="userId">Optional identifier of the user or actor making the changes.</param>
/// <param name="options">Filters narrowing which entities and properties are captured. Null captures everything.</param>
/// <remarks>
/// A separate overload rather than an optional parameter on the one above: optional arguments
/// are baked in at the call site, so adding one to a published method leaves assemblies already
/// compiled against the old signature unable to bind to it.
/// </remarks>
public static IReadOnlyCollection<PendingAuditEntry>
CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId,
AuditOptions? options)
{
changeTracker.DetectChanges();

Expand All @@ -166,7 +210,10 @@ public static IReadOnlyCollection<PendingAuditEntry>
or EntityState.Modified
or EntityState.Deleted))
{
var changes = BuildDiff(entry);
if (options?.ShouldAuditEntity is not null && !options.ShouldAuditEntity(entry))
continue;

var changes = BuildDiff(entry, options);

if (changes.Count == 0)
continue; // nothing meaningful changed
Expand Down Expand Up @@ -282,7 +329,7 @@ public static IReadOnlyCollection<GenericAuditDiffJson>
return state;
}
private static Dictionary<string, PropertyDiff>
BuildDiff(EntityEntry entry)
BuildDiff(EntityEntry entry, AuditOptions? options)
{
var diffs = new Dictionary<string, PropertyDiff>();

Expand All @@ -291,6 +338,9 @@ private static Dictionary<string, PropertyDiff>
if (prop.IsTemporary)
continue;

if (options?.ShouldAuditProperty is not null && !options.ShouldAuditProperty(entry, prop.Metadata))
continue;

if (entry.State == EntityState.Added)
{
diffs[prop.Metadata.Name] =
Expand Down
Loading
Loading