diff --git a/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs b/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs
index 8d82cba..d26f912 100644
--- a/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs
+++ b/SW.EfCoreExtensions.PgSql/AuditBuilderExtensions.cs
@@ -3,6 +3,7 @@
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata;
using SW.PrimitiveTypes;
namespace SW.EfCoreExtensions;
@@ -118,6 +119,34 @@ public sealed class PendingAuditEntry
}
+///
+/// Controls what records. Both filters
+/// default to null, which captures every changed entity and every one of its properties —
+/// the behaviour callers get when they pass no options at all.
+///
+///
+/// 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
+/// snapshot just as it is from a
+/// diff — an exclusion that only covered modifications would still write the secret out in full the
+/// first time the row was inserted.
+///
+public sealed class AuditOptions
+{
+ ///
+ /// Decides whether an entity is audited at all. Return false 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.
+ ///
+ public Func? ShouldAuditEntity { get; init; }
+
+ ///
+ /// Decides whether a single property is captured. Return false and the property appears
+ /// in no diff, whatever state its entity is in.
+ ///
+ public Func? ShouldAuditProperty { get; init; }
+}
+
///
/// Provides extension methods for building audit trails from Entity Framework Core change tracking.
/// Enables capturing, finalizing, and reconstructing entity changes for audit logging purposes.
@@ -139,6 +168,7 @@ public static class AuditBuilderExtension
/// - Generates a shared correlation ID for all changes in this batch
/// - Records a UTC timestamp shared by all changes
/// - Skips entities with no meaningful changes (e.g., only temporary properties changed)
+ /// - Skips entities and properties excluded by
/// - Captures domain events from entities implementing IGeneratesDomainEvents
///
/// Use to convert the results to JSON-serializable format.
@@ -152,6 +182,20 @@ public static class AuditBuilderExtension
///
public static IReadOnlyCollection
CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null)
+ => changeTracker.CapturePendingAuditDiffs(userId, null);
+
+ ///
+ /// The Entity Framework change tracker to capture changes from.
+ /// Optional identifier of the user or actor making the changes.
+ /// Filters narrowing which entities and properties are captured. Null captures everything.
+ ///
+ /// 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.
+ ///
+ public static IReadOnlyCollection
+ CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId,
+ AuditOptions? options)
{
changeTracker.DetectChanges();
@@ -166,7 +210,10 @@ public static IReadOnlyCollection
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
@@ -282,7 +329,7 @@ public static IReadOnlyCollection
return state;
}
private static Dictionary
- BuildDiff(EntityEntry entry)
+ BuildDiff(EntityEntry entry, AuditOptions? options)
{
var diffs = new Dictionary();
@@ -291,6 +338,9 @@ private static Dictionary
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] =
diff --git a/SW.EfCoreExtensions.UnitTests/AuditTests.cs b/SW.EfCoreExtensions.UnitTests/AuditTests.cs
new file mode 100644
index 0000000..3fce025
--- /dev/null
+++ b/SW.EfCoreExtensions.UnitTests/AuditTests.cs
@@ -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());
+ }
+
+ [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();
+ dbContext.Add(NewEmployee());
+
+ var changes = SingleEmployeeEntry(dbContext.ChangeTracker.CapturePendingAuditDiffs()).Changes;
+
+ Assert.IsTrue(changes.ContainsKey(nameof(Employee.UserName)));
+ Assert.IsTrue(changes.ContainsKey(nameof(Employee.Email)));
+ }
+
+ ///
+ /// 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.
+ ///
+ [TestMethod]
+ public void ExcludedPropertyIsAbsentFromAnAddedSnapshot()
+ {
+ using var scope = server.Services.CreateScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ 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();
+
+ 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();
+ 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();
+
+ 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)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 captured) =>
+ captured.Single(c => c.EntityType == typeof(Employee).FullName);
+ }
+}
diff --git a/SW.EfCoreExtensions/AuditBuilderExtensions.cs b/SW.EfCoreExtensions/AuditBuilderExtensions.cs
index 4246e4e..64e45d2 100644
--- a/SW.EfCoreExtensions/AuditBuilderExtensions.cs
+++ b/SW.EfCoreExtensions/AuditBuilderExtensions.cs
@@ -3,6 +3,7 @@
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata;
using SW.PrimitiveTypes;
namespace SW.EfCoreExtensions;
@@ -118,6 +119,34 @@ public sealed class PendingAuditEntry
}
+///
+/// Controls what records. Both filters
+/// default to null, which captures every changed entity and every one of its properties —
+/// the behaviour callers get when they pass no options at all.
+///
+///
+/// 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
+/// snapshot just as it is from a
+/// diff — an exclusion that only covered modifications would still write the secret out in full the
+/// first time the row was inserted.
+///
+public sealed class AuditOptions
+{
+ ///
+ /// Decides whether an entity is audited at all. Return false 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.
+ ///
+ public Func? ShouldAuditEntity { get; init; }
+
+ ///
+ /// Decides whether a single property is captured. Return false and the property appears
+ /// in no diff, whatever state its entity is in.
+ ///
+ public Func? ShouldAuditProperty { get; init; }
+}
+
///
/// Provides extension methods for building audit trails from Entity Framework Core change tracking.
/// Enables capturing, finalizing, and reconstructing entity changes for audit logging purposes.
@@ -139,6 +168,7 @@ public static class AuditBuilderExtension
/// - Generates a shared correlation ID for all changes in this batch
/// - Records a UTC timestamp shared by all changes
/// - Skips entities with no meaningful changes (e.g., only temporary properties changed)
+ /// - Skips entities and properties excluded by
/// - Captures domain events from entities implementing IGeneratesDomainEvents
///
/// Use to convert the results to JSON-serializable format.
@@ -152,6 +182,20 @@ public static class AuditBuilderExtension
///
public static IReadOnlyCollection
CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null)
+ => changeTracker.CapturePendingAuditDiffs(userId, null);
+
+ ///
+ /// The Entity Framework change tracker to capture changes from.
+ /// Optional identifier of the user or actor making the changes.
+ /// Filters narrowing which entities and properties are captured. Null captures everything.
+ ///
+ /// 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.
+ ///
+ public static IReadOnlyCollection
+ CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId,
+ AuditOptions? options)
{
changeTracker.DetectChanges();
@@ -166,7 +210,10 @@ public static IReadOnlyCollection
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
@@ -282,7 +329,7 @@ public static IReadOnlyCollection
return state;
}
private static Dictionary
- BuildDiff(EntityEntry entry)
+ BuildDiff(EntityEntry entry, AuditOptions? options)
{
var diffs = new Dictionary();
@@ -291,6 +338,9 @@ private static Dictionary
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] =
diff --git a/docs/AUDIT.md b/docs/AUDIT.md
index 724ffea..9496d8b 100644
--- a/docs/AUDIT.md
+++ b/docs/AUDIT.md
@@ -14,6 +14,7 @@
- [DomainEventEnvelope](#domaineventenvelope)
- [PendingAuditEntry](#pendingauditentry)
- [GenericAuditDiffJson](#genericauditdifffson)
+ - [AuditOptions](#auditoptions)
3. [Methods](#methods)
- [CapturePendingAuditDiffs](#capturependingauditdiffs)
- [FinalizeAuditDiffJson](#finalizeauditdifffson)
@@ -183,6 +184,43 @@ The **finalized, serializable** audit record. Safe to serialize to JSON and stor
---
+### `AuditOptions`
+
+```csharp
+public sealed class AuditOptions
+{
+ public Func? ShouldAuditEntity { get; init; }
+ public Func? ShouldAuditProperty { get; init; }
+}
+```
+
+Narrows what `CapturePendingAuditDiffs` records. Both filters are optional; leaving them `null`
+captures every changed entity and every property, which is what callers that pass no options get.
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ShouldAuditEntity` | `Func?` | Return `false` to produce no entry for that entity. Use it to keep high-volume runtime tables out of a log meant to record configuration changes. |
+| `ShouldAuditProperty` | `Func?` | Return `false` and the property appears in no diff, whatever state its entity is in. |
+
+**Keeping credentials out of the log:**
+
+```csharp
+var options = new AuditOptions
+{
+ ShouldAuditEntity = entry => entry.Entity is Order or Customer,
+ ShouldAuditProperty = (entry, property) =>
+ !(entry.Entity is Customer && property.Name == nameof(Customer.PasswordHash))
+};
+
+var pending = ChangeTracker.CapturePendingAuditDiffs(_currentUserId, options);
+```
+
+> `ShouldAuditProperty` is applied before the entity state is considered. That matters because an
+> `Added` entity is captured as a **full snapshot** rather than a diff — an exclusion that only
+> covered modifications would still write the secret out in full the first time the row was inserted.
+
+---
+
## Methods
### `CapturePendingAuditDiffs`
@@ -190,8 +228,17 @@ The **finalized, serializable** audit record. Safe to serialize to JSON and stor
```csharp
public static IReadOnlyCollection
CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId = null)
+
+public static IReadOnlyCollection
+ CapturePendingAuditDiffs(this ChangeTracker changeTracker, string? userId,
+ AuditOptions? options)
```
+> Two overloads rather than one with an optional `options`: 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. It also means `options` can't be passed on its own —
+> give `userId` explicitly, `null` if there isn't one.
+
**Call this BEFORE `SaveChanges`.**
Scans the EF Core change tracker for all entities in `Added`, `Modified`, or `Deleted` state and builds a list of `PendingAuditEntry` objects.
@@ -200,6 +247,7 @@ Scans the EF Core change tracker for all entities in `Added`, `Modified`, or `De
|----------------|-----------|----------|-------------|
| `changeTracker`| `ChangeTracker` | yes (extension) | The EF Core change tracker from your `DbContext`. |
| `userId` | `string?` | no | The current user/actor identifier. Pass from your HTTP context, JWT claim, or service identity. Defaults to `null`. |
+| `options` | `AuditOptions?` | yes, on the three-parameter overload | Filters narrowing which entities and properties are captured. See [AuditOptions](#auditoptions). `null` captures everything. |
**Returns:** `IReadOnlyCollection` — one entry per changed entity. Entities with no meaningful property changes (e.g. only EF-internal temporary properties) are excluded.
@@ -209,6 +257,7 @@ Scans the EF Core change tracker for all entities in `Added`, `Modified`, or `De
- Captures a single `DateTimeOffset.UtcNow` timestamp shared by all entries.
- Assigns a `Sequence` counter (1, 2, 3 …) to each entry in the order they were enumerated.
- Skips properties where `prop.IsTemporary == true` (e.g. auto-increment PKs before insert).
+- Skips entities and properties rejected by `options`, in every entity state.
- Captures domain events from entities implementing `IGeneratesDomainEvents`.
---