DKNet

DKNet.EfCore.AuditLogs

A SaveChanges interceptor that captures a structured, field-level change record for every created, updated, or deleted entity, and hands the batch to publishers you register.

✨ Why use it?

Reach for this package when you need an automatic audit trail of entity changes and are willing to plug in your own storage. If you only need EF Core’s change-tracking debug view for troubleshooting, use DbContext.ChangeTracker.DebugView; if you need to protect the value at rest rather than log its history, use DKNet.EfCore.Encryption.

🚀 Quick Start

dotnet add package DKNet.EfCore.AuditLogs

The package depends on DKNet.EfCore.Abstractions (for IAuditedProperties and the [AuditLog]/[IgnoreAuditLog]/[SensitiveData] attributes) and DKNet.EfCore.Hooks (for the SaveChanges pipeline), plus Microsoft.EntityFrameworkCore.

Registering the hook alone is not enough — the DbContext must also be wired into the hook pipeline via AddDbContextWithHook (or a manual options.UseHooks<TDbContext>(provider) call), exactly as for any other DKNet.EfCore.Hooks consumer:

using DKNet.EfCore.AuditLogs;
using DKNet.EfCore.Hooks;
using Microsoft.Extensions.DependencyInjection;

// 1. Register the DbContext through the hook-aware overload.
services.AddDbContextWithHook<AppDbContext>((provider, options) =>
    options.UseSqlServer(connectionString));

// 2. Register the audit hook plus a publisher, keyed to AppDbContext.
services.AddEfCoreAuditLogs<AppDbContext, MyAuditLogPublisher>();

// 3. Optional: fill CreatedBy/UpdatedBy from the signed-in user instead of leaving them to
//    DKNet.EfCore.DataAuthorization's ownership key.
services.AddCurrentUserProvider<AppDbContext, SignedInUserProvider>();

AddEfCoreAuditLogs<TDbContext, TPublisher>() is the one-call setup: it registers TPublisher as a keyed IAuditLogPublisher (keyed by typeof(TDbContext).FullName) and internally calls AddEfCoreAuditHook<TDbContext>(), which registers the AuditLogOptions and adds EfCoreAuditHook via services.AddHook<TDbContext, EfCoreAuditHook>(). If you want the hook without a publisher yet (e.g. registering publishers separately, or several of them), call AddEfCoreAuditHook<TDbContext>() directly and add publishers with services.AddKeyedScoped<IAuditLogPublisher, TPublisher>(typeof(TDbContext).FullName!).

Step 3 is independent of the other two: AddCurrentUserProvider<TDbContext, TProvider>() attaches the audit hook itself, so it works on its own when you only want CreatedBy/UpdatedBy filled and no audit trail published — see ICurrentUserProvider.

🧩 Features

AuditLogEntry — the captured record shape

Every audited operation produces one AuditLogEntry (a sealed record implementing IAuditedProperties from Abstractions):

public sealed record AuditLogEntry : IAuditedProperties
{
    public required AuditLogAction Action { get; init; }       // Created | Updated | Deleted
    public required DateTimeOffset CreatedOn { get; init; }
    public DateTimeOffset? UpdatedOn { get; init; }
    public required IDictionary<string, object?> Keys { get; init; }        // primary-key name -> value
    public required IReadOnlyList<AuditFieldChange> Changes { get; init; }  // field-level diffs
    public required string CreatedBy { get; init; }
    public required string EntityName { get; init; }            // entry.Entity.GetType().Name
    public string? UpdatedBy { get; init; }
}

public sealed record AuditFieldChange
{
    public required string FieldName { get; init; }
    public object? OldValue { get; init; }
    public object? NewValue { get; init; }
}

Keys comes from the entity’s EF-mapped primary key (via the same GetEntityKeyValues() extension used elsewhere in DKNet), so composite keys are represented as multiple dictionary entries. CreatedBy/CreatedOn/UpdatedBy/UpdatedOn are copied from the audited entity itself (it must implement IAuditedProperties), not from the audit entry’s own creation time — and when an ICurrentUserProvider is registered, the same hook has already stamped those properties onto the entity earlier in the same BeforeSaveAsync pass (see ICurrentUserProvider), so the entry and the row it describes can never disagree.

EfCoreAuditHook — how capture happens

EfCoreAuditHook is an internal class in DKNet.EfCore.AuditLogs.Internals that derives from HookAsync, so it implements the combined IHookAsync interface from DKNet.EfCore.Hooks (IBeforeSaveHookAsync + IAfterSaveHookAsync) — the same interface any other hook (domain events, data authorization, …) implements to join the same SaveChanges pipeline. You never construct or reference EfCoreAuditHook directly; AddEfCoreAuditHook<TDbContext> registers it for you.

Its mechanics, split across the two save phases:

For Created entities, Changes is always empty — the field-diff loop only runs when the original state is not Added — so a create audit entry carries Action = Created, Keys, and the audit metadata, but no field-level detail. For Deleted entities, every captured property gets NewValue = null and OldValue set to the last known value (or the redaction sentinel).

ICurrentUserProvider — who the change is attributed to

namespace DKNet.EfCore.AuditLogs;

public interface ICurrentUserProvider
{
    string? GetCurrentUser();
}

Implement it over whatever your application already uses to represent the caller, and register it with AddCurrentUserProvider<TDbContext, TProvider>():

public sealed class SignedInUserProvider(ICurrentPrincipal principal) : ICurrentUserProvider
{
    // Return a stable, non-personal identifier — see the privacy note at the end of this section.
    public string? GetCurrentUser() => principal.SubjectId; // e.g. "sub-8f21c0"
}

services.AddDbContextWithHook<AppDbContext>((provider, options) => options.UseSqlServer(connectionString));
services.AddCurrentUserProvider<AppDbContext, SignedInUserProvider>();

What that one call does, from EfCoreAuditLogSetup:

Stamping happens in StampCurrentUser, called at the top of BeforeSaveAsync before the audit entries are built — which is what makes a published entry carry the same values the row was saved with:

Original state Stamped from the current user Left alone when
Added CreatedBy, CreatedOn (DateTimeOffset.UtcNow) CreatedBy is already non-empty — first write wins, so a domain factory’s SetCreatedBy(...) survives
Modified UpdatedBy, UpdatedOn (DateTimeOffset.UtcNow) a domain method already changed UpdatedBy/UpdatedOn in this change set (SetUpdatedBy(...)), or the entity has no mapped UpdatedBy property
Deleted nothing always — a delete is recorded in the trail, never stamped

GetCurrentUser() returning null or empty makes the whole pass a no-op: the save still succeeds and this package writes nothing.

Composing with DKNet.EfCore.DataAuthorization

Both providers are optional and each works without the other. DataOwnerHook takes the same ICurrentUserProvider as an optional dependency and decides from its value for that save, never from hook registration order — so the two hooks cannot fight over the audit fields:

Registered CreatedBy/UpdatedBy come from OwnedBy comes from
current-user provider only GetCurrentUser() not stamped
ownership provider only IDataOwnerProvider.GetOwnershipKey() the same ownership key
both, GetCurrentUser() returned a value GetCurrentUser() the ownership key
both, GetCurrentUser() returned null/empty the ownership key — the pre-existing behaviour the ownership key

The last row is why adding a current-user provider is a backwards-compatible change: a background job or an unauthenticated request for which the provider has no user still lands the tenant key in CreatedBy/UpdatedBy, exactly as before. With neither provider supplying a value, the audit properties are left as the entity set them.

Privacy: the value is published unmasked

Whatever GetCurrentUser() returns reaches every registered IAuditLogPublisher in full — the redaction rules above cover entity property values, not the audit identity itself. An application subject to a personal-data rule (GDPR, PDPA) should therefore return a stable, non-personal identifier such as the token subject id ("sub-8f21c0"), rather than an email address or any other directly identifying value.

This does not apply to the error log written when a publisher throws (see Gotchas & limits): that log names the publisher, the entity name(s), and the entry count — it no longer serializes the failed batch’s entry values, so a publisher failure cannot itself leak a redacted or unredacted value into the application log.

IAuditLogPublisher — where the entries go

public interface IAuditLogPublisher
{
    Task PublishAsync(IEnumerable<AuditLogEntry> logs, CancellationToken cancellationToken = default);
}

This is the extension point: implement it to ship a SaveChangesAsync call’s audit batch to a database table, queue, log sink, or anywhere else. Publishers are registered as keyed scoped services, keyed by typeof(TDbContext).FullName, so different DbContext types can have entirely different publishers, and multiple publishers can be registered for the same DbContext (all are invoked; one publisher throwing does not stop the others — see Gotchas & limits).

public sealed class ConsoleAuditLogPublisher : IAuditLogPublisher
{
    public Task PublishAsync(IEnumerable<AuditLogEntry> logs, CancellationToken cancellationToken = default)
    {
        foreach (var log in logs)
        {
            var fields = string.Join(", ", log.Changes.Select(c => $"{c.FieldName}: {c.OldValue} -> {c.NewValue}"));
            Console.WriteLine($"[{log.Action}] {log.EntityName} by {log.UpdatedBy ?? log.CreatedBy}{fields}");
        }
        return Task.CompletedTask;
    }
}

Registering more than one publisher for the same DbContext:

services.AddEfCoreAuditHook<AppDbContext>(); // hook only, no publisher yet
services.AddKeyedScoped<IAuditLogPublisher, ConsoleAuditLogPublisher>(typeof(AppDbContext).FullName!);
services.AddKeyedScoped<IAuditLogPublisher, DatabaseAuditLogPublisher>(typeof(AppDbContext).FullName!);

To resolve the publishers registered for a DbContext type yourself (e.g. in a test, or for manual invocation), use the GetAuditLogPublishers<TDbContext>() extension on IServiceProvider:

var publishers = serviceProvider.GetAuditLogPublishers<AppDbContext>();

Sensitive-data redaction and its interaction with the Abstractions attributes

SensitiveDataPatterns (internal to this package) is a hardcoded deny-list of name fragments — password, secret, token, apikey, api_key, ssn, socialsecuritynumber, creditcard, cvv, pin, connectionstring, privatekey, passphrase, accesskey, salt — matched case-insensitively against the property name, plus any property of CLR type System.Security.SecureString. A match causes the field’s OldValue/NewValue to be replaced with the sentinel string "***REDACTED***" in the captured AuditFieldChange — the field still appears in Changes (so you can see it changed) but never its value.

This interacts with three attributes defined in DKNet.EfCore.Abstractions.Attributes, plus [Encrypted] from DKNet.EfCore.Encryption:

public sealed class Customer : AuditedEntity<Guid>
{
    [Encrypted] // encrypted at rest, and always redacted in the audit trail
    public string? TaxId { get; set; }
}

Redaction here is unchanged by role-gated API filtering. [SensitiveData] now accepts optional role names, but this package ignores them: a declared-sensitive value is redacted in every audit entry, for every reader, exactly as before. What the roles do — withhold the property from an API response for callers who don’t hold one — is DKNet.EfCore.Extensions’ opt-in and never touches audit capture; see Withhold sensitive properties from unauthorised callers. The reverse is also worth stating plainly: the built-in name deny-list is an audit-log default only. A property redacted because its name contains token or apikey, with no attribute on it, is still returned in full by the API — the response filter acts on an explicit [SensitiveData] declaration and nothing else. That gap is a deliberate, accepted decision rather than an oversight; close it for a given property by declaring it [SensitiveData].

public sealed class ApiClient : AuditedEntity<Guid>
{
    public required string Name { get; set; }

    [AuditLog] // name matches the "token" pattern, but this forces plaintext capture
    public DateTimeOffset TokenExpiryUtc { get; set; }

    [SensitiveData] // always redacted, regardless of name or [AuditLog]
    public string? InternalNotes { get; set; }

    [IgnoreAuditLog] // never appears in Changes at all
    public byte[]? Thumbnail { get; set; }
}

⚙️ Configuration reference

Both AddEfCoreAuditHook<TDbContext> and AddEfCoreAuditLogs<TDbContext, TPublisher> take the same two optional parameters — there is no options class to configure post-registration:

Option Type Default Effect
behaviour AuditLogBehaviour IncludeAllAuditedEntities IncludeAllAuditedEntities audits every IAuditedProperties entity not marked [IgnoreAuditLog]; OnlyAttributedAuditedEntities audits only entities marked [AuditLog] at class level.
propertyPolicy AuditPropertyPolicy RedactSensitive RedactSensitive captures every non-ignored property, replacing sensitive-looking values with "***REDACTED***"; OnlyAttributedProperties captures only properties marked [AuditLog] and omits the rest.

AddCurrentUserProvider<TDbContext, TProvider>() takes no arguments at all. It registers those same defaults only when no AuditLogOptions is registered yet, so non-default values must come from an AddEfCoreAuditHook/AddEfCoreAuditLogs call — in either order, since neither call overwrites the other’s options.

The two values reach the hook through an internal AuditLogOptions singleton, so they are fixed at registration time for the whole application — there is no per-save or per-entity override.

public enum AuditLogBehaviour
{
    IncludeAllAuditedEntities,     // default: every IAuditedProperties entity is audited
    OnlyAttributedAuditedEntities  // only entities marked [AuditLog] at class level are audited
}

public enum AuditPropertyPolicy
{
    RedactSensitive,          // default: capture every non-ignored property, redact sensitive ones
    OnlyAttributedProperties  // capture only properties explicitly marked [AuditLog]; omit everything else
}

services.AddEfCoreAuditLogs<AppDbContext, MyAuditLogPublisher>(
    behaviour: AuditLogBehaviour.OnlyAttributedAuditedEntities,
    propertyPolicy: AuditPropertyPolicy.OnlyAttributedProperties);

Defaults (AuditLogBehaviour.IncludeAllAuditedEntities + AuditPropertyPolicy.RedactSensitive) favor completeness: every IAuditedProperties entity not explicitly opted out via [IgnoreAuditLog] gets audited, and sensitive-looking fields are redacted rather than omitted. Switch both to the OnlyAttributed* values for an explicit allow-list model where nothing is captured unless a developer opted it in with [AuditLog].

🧱 Where it fits

Two gates decide what ends up in the trail: an entity-level gate driven by AuditLogBehaviour, and a property-level gate driven by AuditPropertyPolicy and the redactor:

Data-flow diagram of audit capture: snapshot entries pass an entity gate that requires IAuditedProperties and honours the configured behaviour, then a property gate that applies AuditPropertyPolicy and routes sensitive values through the redactor, producing an AuditLogEntry that is published after the write to every IAuditLogPublisher keyed to the DbContext.

⚠️ Gotchas & limits