This is another blog post that has been in the pipeline for quite some time. As the title already states, it’s about audit logging with Audit.NET in .NET Core applications which make use of Entity Framework Core.
Let’s start with the what, followed by the why, and finally the concrete implementation (the how) will be presented.
The WHAT
Audit logging is about generating and persisting tracking information for different operations. This blog post covers the generation of tracking information for create, update and delete operations executed by Entity Framework Core. The audit logs are stored in a dedicated database table using Entity Framework Core. The presented approach can also be applied to .NET Core applications orchestrated by .NET Aspire.
Versions
The WHY
Audit logs / audit trails are particularly helpful for traceability and compliance with data protection regulations. When implemented correctly, all changes to entities can be traced precisely, which can be a time-saver during analysis and troubleshooting.
Why Audit.NET? Audit.NET is an open source library licensed under the MIT license, which supports a broad range of interaction extensions (i.e. Entity Framework, Web API, file system, …), output extensions (i.e. JSON files, Event Log, SQL, …) and configuration options.
The HOW
Personally, I prefer to maintain the following properties on (nearly) each entity, which automatically simplifies audit logging.
CreatedAtCreatedByOidModifiedAt(can be used as concurrency token in EF Core)ModifiedByOid
An easy way to do so, is to create an abstract base class for entity classes.
public abstract class EntityTrackingBase
{
public DateTime CreatedAt { get; set; }
public Guid CreatedByOid { get; set; }
public DateTime ModifiedAt { get; set; }
public Guid ModifiedByOid { get; set; }
}
IMPORTANT: CreatedByOid and ModifiedByOid should NOT be modeled as foreign keys, otherwise the user who created/edited the entity cannot be deleted independently of the entity.
This base class is then inherited by the entity classes.
Next, the DbContext implementation can be modified as follows.
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
// Define DbSet properties here
/// <inheritdoc />
public override int SaveChanges()
{
UpdateTrackingEntityProperties();
return base.SaveChanges();
}
/// <inheritdoc />
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
UpdateTrackingEntityProperties();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void UpdateTrackingEntityProperties()
{
var currentUsersOid = UserContextHelper.GetCurrentOid();
var utcNow = DateTime.UtcNow;
var addedEntities = ChangeTracker
.Entries()
.Where(e => e.State == EntityState.Added && e.Entity is EntityTrackingBase)
.Select(p => (EntityTrackingBase)p.Entity);
foreach (var entityTrackingBase in addedEntities)
{
entityTrackingBase.CreatedAt = utcNow;
entityTrackingBase.CreatedByOid = currentUsersOid;
entityTrackingBase.ModifiedAt = entityTrackingBase.CreatedAt;
entityTrackingBase.ModifiedByOid = entityTrackingBase.CreatedByOid;
}
var modifiedEntities = ChangeTracker
.Entries()
.Where(e => e.State == EntityState.Modified && e.Entity is EntityTrackingBase)
.Select(p => (EntityTrackingBase)p.Entity);
foreach (var entityTrackingBase in modifiedEntities)
{
entityTrackingBase.ModifiedAt = utcNow;
entityTrackingBase.ModifiedByOid = currentUsersOid;
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
...
}
}
SaveChanges and SaveChangesAsync are overridden and call UpdateTrackingEntityProperties method which sets the entity properties inherited by EntityTrackingBase.
For the sake of completeness, here is the code for the UserContextHelper class.
using Microsoft.AspNetCore.Http;
namespace ArbitraryNamespace;
internal static class UserContextHelper
{
// TODO: to be adjusted, if not using Entra ID authentication
private const string _objectIdentifierClaimType = "http://schemas.microsoft.com/identity/claims/objectidentifier";
internal static Guid GetCurrentOid()
{
try
{
var httpContext = new HttpContextAccessor().HttpContext;
if (httpContext != null && httpContext.User.Identity != null)
{
var objectIdentifier =
httpContext.User.Claims.FirstOrDefault(p => p.Type == _objectIdentifierClaimType);
if (objectIdentifier != null)
{
return Guid.Parse(objectIdentifier.Value);
}
}
}
catch (Exception)
{
return Guid.Empty;
}
return Guid.Empty;
}
}
When implemented correctly, Guid.Empty is only returned for operations executed without user context (i.e. operations executed by background jobs).
Let’s now dig into the implementation of the audit logging / audit trail.
To save the audit log / audit trail records, a new entity class needs to be created.
using System;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
namespace ArbitraryNamespace;
/// <summary>
/// Represents a record of the audit trail
/// </summary>
public class AuditTrailRecord
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>
/// Action that gets audited by this record
/// </summary>
[MaxLength(255)]
public string Action { get; set; } = string.Empty;
/// <summary>
/// Type of the entity to be audited
/// </summary>
[MaxLength(255)]
public string EntityType { get; set; } = string.Empty;
/// <summary>
/// Primary key of the entity to be audited
/// </summary>
public long EntityId { get; set; }
/// <summary>
/// Details concerning the action as JSON (i.e. old and new values of the properties of the entity)
/// </summary>
public string AuditData { get; set; } = string.Empty;
}
This entity class intentionally does not inherit from EntityTrackingBase.
Do not forget to add a DbSet property for the AuditTrailRecord entity class to the ApplicationDbContext.
public DbSet<AuditTrailRecord> AuditTrail { get; set; }
Next, the Audit.NET library has to be added to the corresponding .NET project (Audit.EntityFramework.Core) and needs to be configured accordingly.
using Audit.Core;
using Audit.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
...
// .NET Aspire style - name of the database resource is passed here
// var sqlServer = builder.AddAzureSqlServer("sqlserver");
// var database = sqlServer.AddDatabase("database", "WebApp");
builder.AddSqlServerDbContext<ApplicationDbContext>("database", configureDbContextOptions: options =>
{
options.UseSqlServer(o =>
{
...
})
.AddInterceptors(new AuditSaveChangesInterceptor());
});
builder.EnrichSqlServerDbContext<ApplicationDbContext>();
builder.Services.AddDbContextFactory<ApplicationDbContext>();
Audit.Core.Configuration.Setup()
.UseEntityFramework(config => config
.AuditTypeMapper(_ => typeof(AuditTrailRecord))
.AuditEntityAction<AuditTrailRecord>((auditEvent, entry, entity) =>
{
entity.CreatedAt = DateTime.UtcNow;
entity.Action = entry.Action;
entity.EntityType = entry.Name;
entity.EntityId = (int)entry.PrimaryKey.First().Value;
entity.AuditData = entry.ToJson();
return Task.FromResult(true);
})
.IgnoreMatchedProperties());
Audit.EntityFramework.Configuration.Setup()
.ForContext<ApplicationDbContext>()
.UseOptOut()
.Ignore<AuditTrailRecord>();
Last but not least, don’t forget to add a new migration to make the audit logging / audit trail work!

Leave a comment