Advanced Audit Logging in .NET Core Using Middleware
Supporting Classes
public class AuditLog { public Guid Id { get; set; } public bool IsDeleted { get; set; } public DateTime CreatedDate { get; set; } public string? CreatorId { get; set; } public string? CorrelationId { get; set; } public AuditLogEventTypes AuditLogEventType { get; set; } public string? EntityId { get; set; } public string? EntityName { get; set; } public string? UserName { get; set; } public string? UserRole { get; set; } public string? SessionId { get; set; } public string? ClientIpAddress { get; set; } public string? BrowserInfo { get; set; } public string? ApplicationName { get; set; } public string? MachineName { get; set; } public string? MachineVersion { get; set; } public string? MachineOsVersion { get; set; } public string? ServiceName { get; set; } public string? MethodName { get; set; } public string? HttpMethod { get; set; } public string? RequestUrl { get; set; } public string? ResponseStatus { get; set; } public DateTime? ExecutionTime { get; set; } public string? ExecutionDuration { get; set; } public string? RequestHeaders { get; set; } public string? QueryParameters { get; set; } public string? BodyParameters { get; set; } public string? Exception { get; set; } public ICollection<EntityPropertyChange> EntityPropertyChanges { get; set; } = new List<EntityPropertyChange>(); } public class EntityPropertyChange { public Guid Id { get; set; } public string? PropertyName { get; set; } public string? OriginalValue { get; set; } public string? NewValue { get; set; } public string? PropertyTypeFullName { get; set; } public Guid AuditLogId { get; set; } } public class AuditScope { public List<AuditLog> Logs { get; set; } = new List<AuditLog>(); }
Audit Logging with Entity Tracking in DbContext
public class AppDbContext : DbContext { private readonly IHttpContextAccessor _httpContextAccessor; private readonly AuditScope _auditScope; public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options, IHttpContextAccessor httpContextAccessor, AuditScope auditScope) : base(options) { _httpContextAccessor = httpContextAccessor; _auditScope = auditScope; } public override int SaveChanges() { var auditEntries = OnBeforeSaveChanges(); var result = base.SaveChanges(); OnAfterSaveChanges(auditEntries); return result; } public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) { var auditEntries = OnBeforeSaveChanges(); var result = await base.SaveChangesAsync(cancellationToken); OnAfterSaveChanges(auditEntries); return result; } private List<EntityEntry> OnBeforeSaveChanges() { return ChangeTracker.Entries().ToList(); } private void OnAfterSaveChanges(List<EntityEntry> auditEntries) { foreach (var entry in auditEntries) { if (entry.Entity is AuditLog) continue; var tableName = entry.Metadata.GetTableName(); var auditLog = new AuditLog(); auditLog.Id = Guid.NewGuid(); auditLog.AuditLogEventType = entry.State switch { EntityState.Added => AuditLogEventTypes.Added, EntityState.Modified => AuditLogEventTypes.Modified, EntityState.Deleted => AuditLogEventTypes.Deleted, EntityState.Unchanged => AuditLogEventTypes.Unchanged, EntityState.Detached => AuditLogEventTypes.Detached, _ => AuditLogEventTypes.Unknown }; auditLog.EntityId = entry.OriginalValues[entry.Metadata.FindPrimaryKey()!.Properties.First().Name]!.ToString(); auditLog.EntityName = tableName; auditLog.UserName = _httpContextAccessor.HttpContext?.User?.GetUserName(); auditLog.UserRole = _httpContextAccessor.HttpContext?.User?.GetUserRole() != null ? string.Join(",", _httpContextAccessor.HttpContext.User.GetUserRole()!) : null; foreach (var property in entry.Properties) { var columnName = property.Metadata.Name; var oldValue = property.OriginalValue?.ToString(); var newValue = property.CurrentValue?.ToString(); if (!Equals(oldValue, newValue)) { var entityPropertyChange = new EntityPropertyChange { Id = Guid.NewGuid(), NewValue = newValue, OriginalValue = oldValue, PropertyName = columnName, PropertyTypeFullName = property.Metadata.ClrType.FullName, AuditLogId = auditLog.Id }; auditLog.EntityPropertyChanges.Add(entityPropertyChange); } } _auditScope.Logs.Add(auditLog); } } }
The AuditLogMiddleware Class
This middleware captures a wide range of information, including HTTP request/response details, execution times, and exception messages.
public class AuditLogMiddleware { private readonly RequestDelegate _next; private readonly IConfiguration _configuration; public AuditLogMiddleware(RequestDelegate next, IConfiguration configuration) { _next = next; _configuration = configuration; } public async Task InvokeAsync(HttpContext context, AuditScope auditScope) { var stopwatch = Stopwatch.StartNew(); var executionTime = DateTime.UtcNow; var applicationName = _configuration.GetApplicationName(); var correlationId = context.TraceIdentifier; var originalBodyStream = context.Response.Body; using var responseBody = new MemoryStream(); context.Response.Body = responseBody; await _next(context); stopwatch.Stop(); if (auditScope.Logs.Any()) { foreach (var auditLog in auditScope.Logs) { var forwardedHeader = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); var ipAddress = string.IsNullOrEmpty(forwardedHeader) ? context.Connection.RemoteIpAddress?.ToString() : forwardedHeader.Split(',')[0]; auditLog.CorrelationId = correlationId; auditLog.SessionId = context.User.GetUserCustomProperty("SessionId"); auditLog.ClientIpAddress = ipAddress; auditLog.BrowserInfo = context.Request.Headers["User-Agent"]; auditLog.ApplicationName = applicationName; auditLog.MachineName = Environment.MachineName; auditLog.MachineVersion = Environment.Version.ToString(); auditLog.MachineOsVersion = Environment.OSVersion.ToString(); var routeData = context.GetRouteData(); auditLog.ServiceName = routeData?.Values["controller"]?.ToString(); auditLog.MethodName = routeData?.Values["action"]?.ToString(); auditLog.HttpMethod = context.Request.Method; auditLog.RequestUrl = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}"; auditLog.ResponseStatus = context.Response.StatusCode.ToString(); auditLog.ExecutionTime = executionTime; auditLog.ExecutionDuration = stopwatch.ElapsedMilliseconds.ToString(); auditLog.RequestHeaders = JsonConvert.SerializeObject( context.Request.Headers.ToDictionary( header => header.Key, header => header.Value.ToString() ) ); auditLog.QueryParameters = JsonConvert.SerializeObject( context.Request.Query.ToDictionary( query => query.Key, query => query.Value.ToString() ) ); if (context.Request.Body.CanSeek == true) { context.Request.Body.Position = 0; } else { context.Request.EnableBuffering(); } string originalContent; using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true)) { originalContent = await reader.ReadToEndAsync(); context.Request.Body.Position = 0; } var bodyParameters = JsonConvert.DeserializeObject<Dictionary<string, object>>(originalContent); if (bodyParameters != null && bodyParameters.Any()) { var bodyContent = JsonConvert.SerializeObject(bodyParameters); auditLog.BodyParameters = bodyContent; } if (context.Response.StatusCode >= 400) { responseBody.Seek(0, SeekOrigin.Begin); var responseText = await new StreamReader(responseBody).ReadToEndAsync(); auditLog.Exception = responseText; responseBody.Seek(0, SeekOrigin.Begin); await responseBody.CopyToAsync(originalBodyStream); } } } } }
Program.cs
app.UseMiddleware<AuditLogMiddleware>(); app.UseRouting(); app.UseAuthorization(); builder.Services.AddScoped<AuditScope>();