We already have a data audit framework based on EF Core and fully implemented in the DbContextBase abstract class. It will send a DataEvent via MediatR’s notification, the object contains:
Only entities marked as auditable will have audit events triggeredd on their changes. You can mark an entity as auditable by two ways:
public class SampleEntity: IAuditable{
}
// OR
// Inside DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<SampleEntity>(entity =>
{
...
entity.IsAuditable();
...
});
}
If your DbContext inherits from the abstract class DbContextBase, please bypass this step. But if you want to implement a DbContext yourself, it must inherit IAuditableDbContext interface and you can follow these steps to configure:
ConfigureServices: we need User info and IMediator service to function.
// Dependency injection
services.AddPooledDbContextFactory<SampleDbContext>();
services.AddScoped<SampleDbContextScopedFactory>();
services.AddScoped(sp => sp.GetRequiredService<SampleDbContextScopedFactory>().CreateDbContext());
// Factory
public class SampleDbContextScopedFactory : IDbContextFactory<SampleDbContext>
{
private readonly IDbContextFactory<SampleDbContext> _pooledFactory;
private readonly IServiceProvider _serviceProvider;
public SampleDbContextScopedFactory(
IDbContextFactory<SampleDbContext> pooledFactory,
IServiceProvider serviceProvider)
{
_pooledFactory = pooledFactory;
_serviceProvider = serviceProvider;
}
public SampleDbContext CreateDbContext()
{
var context = _pooledFactory.CreateDbContext();
context.ConfigureServices(_serviceProvider);
return context;
}
}
Configure model to mark auditable entities.
Store the audit information before you SaveChanges and finally dispatch data change events.
The code block below is an example for an auditable DbContext.
public class SampleDbContext : DbContext,
IAuditableDbContext
{
#region Auditable context
public string? User { get; protected set; }
private IHttpContextAccessor? _httpContextAccessor;
public List<AuditEntry>? PendingAuditEntries { get; protected set; }
#endregion
protected IMediator? _mediator;
protected ILogger? _logger;
protected DbOptions? _options;
/// <summary>
/// Please call <c>ConfigureServices(IServiceProvider serviceProvider)</c> directly in your constructor
/// or inside <c>IDbContextFactory.CreateDbContext()</c> if you are using PooledDbContextFactory</para>
/// to init internal services</para>
/// </summary>
/// <param name="options"></param>
public SampleDbContext(DbContextOptions options)
: base(options)
{
}
public virtual void ConfigureServices(IServiceProvider serviceProvider)
{
// Get user from HttpContext
var httpContextAccessor = serviceProvider.GetService<IHttpContextAccessor>();
User = httpContextAccessor?.HttpContext?.User?.FindFirst(ClaimTypes.Name)?.Value;
try
{
_mediator = serviceProvider.GetService<IMediator>();
}
catch (Exception ex)
{
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Mark all entities that inherit IAuditable as auditable
modelBuilder.ConfigureAuditableEntities();
// OR/ AND
// Mark specified entities as auditable
modelBuilder.Entity<SampleEntity>(entity =>
{
...
entity.IsAuditable();
...
});
}
private void ProcessingChanges()
{
if (PendingAuditEntries == null)
{ return; }
_mediator.DispatchDataChangeEventsAsync(this).GetAwaiter().GetResult();
}
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
{
this.SetAuditInformation(_logger);
PendingAuditEntries = _mediator != null ? this.TrackingChanges(_logger)?.ToList() : default;
try
{
return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
finally
{
ProcessingChanges();
}
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
this.SetAuditInformation(_logger);
PendingAuditEntries = _mediator != null ? this.TrackingChanges(_logger)?.ToList() : default;
try
{
return base.SaveChanges(acceptAllChangesOnSuccess);
}
finally
{
ProcessingChanges();
}
}
}
In this part, we provide not only data audit service but access logging service.
The AuditContext contains information about user access log, data audit entries. It will be initialized while a request is starting, be fulfilled while the request pipeline is invoking, and be committed by the IAuditService when the request pipeline is finished.
The accessor to access AuditContext in the scope.
The service that processes the audited information to store or send it to other systems.
The middleware that inits the AuditContext at the start of the request. It also collects the important information about the request:
It must be placed after Authentication middleware to have User information.
NOTE: It often increases by 30ms - 70ms in request response time because saves the audit information with the EF repository or gRPC (tested with SQL Server and PostgreSQL on Docker, it depends on whether there are any DataAudit entries or not, and database provider).
The figure below is the request processing flow with AuditMiddleware injected.
We currently provide two ways to use the audit services.
Use the AuditMiddleware to collect audit information and directly persist into the EF repository.
// Minimal application Program.cs
using Juice.Audit.AspNetCore.Extensions;
...
var builder = WebApplication.CreateBuilder(args);
// configure Audit services with default usage
builder.Services.ConfigureAuditDefault(builder.Configuration, options =>
{
// configure database options
//options.DatabaseProvider = "PostgreSQL";
});
// MediatR service is required for Audit services
builder.Services.AddMediatR(typeof(Program));
...
var app = builder.Build();
...
// if specified, the authentication middleware must place before the AuditMiddleware to have User info.
// app.UseAuthentication();
// use AuditMiddleware to handle audit for specified request
app.UseAudit("yourAppName", options =>
{
options.Include("", "GET");
options.Exclude("/Index");
});
Use the AuditMiddleware to collect audit information and remotely persist to the audit server by gRPC.
In this usage, we will use ConfigureAuditGrpcClient() instead of ConfigureAuditDefault() for the application.
// Minimal application Program.cs
using Juice.Audit.AspNetCore.Extensions;
...
var builder = WebApplication.CreateBuilder(args);
// configure Audit services to use gRPC client
builder.Services.ConfigureAuditGrpcClient(options =>
{
options.Address = new Uri("https://localhost:7285"); // the audit server endpoint
// configure grpc client factory options
options.ChannelOptionsActions.Add(o =>
{
o.HttpHandler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
EnableMultipleHttp2Connections = true
};
});
});
// MediatR service is required for Audit services
builder.Services.AddMediatR(typeof(Program));
...
var app = builder.Build();
...
// if specified, the authentication middleware must place before the AuditMiddleware to have User info.
// app.UseAuthentication();
// use AuditMiddleware to handle audit for specified request
app.UseAudit("yourAppName", options =>
{
options.Include("", "GET");
options.Exclude("/Index");
});
We also need an audit server to handle the audit messages.
// Audit server Program.cs
using Juice.Audit.AspNetCore.Extensions;
...
var builder = WebApplication.CreateBuilder(args);
// configure Audit services as a server
builder.Services.ConfigureAuditGrpcHost(builder.Configuration,
options =>
{
options.DatabaseProvider = "PostgreSQL";
});
// MediatR and Grpc services is required for Audit services
builder.Services.AddMediatR(typeof(Program));
builder.Services.AddGrpc(o => o.EnableDetailedErrors = true);
...
var app = builder.Build();
...
// map gPRC endpoint to handle audit messages
app.MapAuditGrpcServer();
Please follow this link to read about rRPC performance tips.
You can configure DbOptions to specify database provider, schema… See DbOptions for more information.
You can configure AuditFilterOptions to specify the paths and methods you want to audit, as well as request/response headers to store in the access log.
Some main methods of AuditFilterOptions that help us build the filter options:
NOTE
The code blocks below are two ways to configure the audit filter
//Program.cs
var configs = new AuditFilterOptions();
builder.Configuration.Bind("Audit", configs);
app.UseAudit("yourAppName", options =>
{
// describe the paths and methods to logging user acess and data audit
options.Include("", "GET"); // include any GET request
options.Exclude("/Index"); // exclude requests to /Index
// include all POST, PUT request to the path that:
// - starts with /kernel
// - has one or more segment after
// Ex: /kernel/foo, /kernel/foo/bar, /kernel/foo/bar/barbar ...
options.Include("/kernel/*/#", "POST", "PUT");
// merge filter entries from appsettings
options.Merge(configs.Filters);
// Only store the request headers like: accept, accept-encoding, accept-language-x,
// content-type, content-length... but not content, content-type-y
options.StoreEmptyRequestHeaders()
.StoreRequestHeaders("accept-#", "content-*");
});
//appsettings.json
"Audit:Filters": [ // path filter antries
{
"Methods": [ "POST", "PUT", "DELETE" ],
"Priority": -1
},
{
"IsExcluded": true,
"Path": "/#/negotiate"
},
{
"IsExcluded": true,
"Path": "/error/*"
},
{
"StatusCodes": [400, 500]
}
]
The model presents the filter entry here
class PathFilterEntry
{
public int Priority { get; set; } = 0;
public bool IsExcluded { get; set; } = false;
public string Path { get; set; } = string.Empty;
public string[] Methods { get; set; } = Array.Empty<string>();
public int[] StatusCodes { get; set; } = Array.Empty<int>();
}
The Data, Msg, Err of ResponseInfo can only be assigned values once. In AuditMiddleware, we do not try to set Data but rather Msg and Err by handling the pipeline call exception. To set Msg, Err, Data in other middlewares or action filter, we can use IAuditContextAccessor service to access the AuditContext and then try to set the response information.
auditContextAccessor.AuditContext?.UpdateResponseInfo(response =>
{
response.TrySetMessage("An useful message");
response.TrySetData("{\"foo\":\"bar\"}");
});
By default, we use DataEvenNotificationtHandler to handle DataEvent and add DataAudit entries, but you can add DataAudit entries manually by access the AuditContext.
auditContextAccessor.AuditContext?.AddAuditEntries(entry1, entry2...);
If the current audit information is not enough, you can add more information to the Metadata by access the AuditContext.
auditContextAccessor.AuditContext?.AccessRecord?.SetMetadata(key, value);
// OR
auditContextAccessor.AuditContext?.AccessRecord?.SetMetadataJson(jsonString);
The library can be accessed via Nuget: