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`. ---