Audit Logs
Comprehensive operation tracking with user IP, action types, mutation logs, and tamper-proof storage.
Tamper-proof transactional log persistence capturing client state, mutation payloads, and full request-response context.
AuditBehavior Architecture
The AuditBehavior runs for every command (not queries) at pipeline position 6, just before the handler. It captures the request via JSON serialization and persists the AuditLog entry synchronously in the same database transaction as the entity mutation. This ensures atomicity — the audit record and the entity change commit together or both roll back.
// Pipeline position 6 — runs for commands only (not queries)
public class AuditBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
// Serialize BEFORE calling handler (payload is the request state)
var payload = JsonSerializer.Serialize(request);
var auditLog = new AuditLog
{
CommandName = typeof(TRequest).Name,
Payload = payload,
UserId = _currentUser.Id,
TenantId = _currentUser.TenantId,
IpAddress = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
UserAgent = _httpContextAccessor.HttpContext?.Request.Headers.UserAgent,
};
await _auditLogRepository.AddAsync(auditLog, ct);
// Saved in same transaction as the entity mutation
var response = await next();
return response;
}
}AuditLog Entity
The AuditLog entity is append-only. It inherits from BaseEntity (not AuditableEntity) to avoid recursive audit-of-audit cycles. The payload is stored as serialized JSON for full-text search capability. CommandName identifies the CQRS command class name.
// Inherits BaseEntity only — NOT AuditableEntity (prevents recursive audit loops)
public class AuditLog : BaseEntity
{
public string CommandName { get; set; } = string.Empty; // e.g. "CreateAdminCommand"
public string Payload { get; set; } = string.Empty; // Serialized command JSON
public Guid? UserId { get; set; } // Null for system operations
public Guid? TenantId { get; set; } // Null for super-admin ops
public Guid? EntityId { get; set; } // Entity affected (if applicable)
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}public class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
{
public void Configure(EntityTypeBuilder<AuditLog> builder)
{
builder.ToTable("AuditLogs");
builder.HasKey(x => x.Id);
builder.Property(x => x.CommandName).HasMaxLength(256).IsRequired();
builder.Property(x => x.Payload).HasColumnType("nvarchar(max)"); // SQL Server
builder.Property(x => x.IpAddress).HasMaxLength(64);
builder.Property(x => x.UserAgent).HasMaxLength(512);
// Composite index for the most common query pattern
builder.HasIndex(x => new { x.TenantId, x.CreatedAt });
// Partial index for EntityId lookups
builder.HasIndex(x => x.EntityId);
}
}Searching Audit Logs
The AuditLogsController exposes a paginated GET endpoint with filters for EntityId, CommandName, UserId, TenantId, and date range. Results are sorted descending by CreatedAt. The table uses a composite index on (TenantId, CreatedAt) and a partial index on EntityId for optimal query performance.
| Filter Parameter | Type | Description |
|---|---|---|
| entityId | string (encrypted) | Filter by entity affected |
| commandName | string | Filter by CQRS command class name |
| userId | string (encrypted) | Filter by acting user |
| tenantId | string (encrypted) | Filter by tenant (super-admin only) |
| from | ISO 8601 date | CreatedAt >= from |
| to | ISO 8601 date | CreatedAt <= to |
| page | int (default: 1) | Pagination page number |
| pageSize | int (max: 100) | Results per page |
Retention Policy
A configurable AuditLogCleanupJob runs daily and permanently deletes AuditLog entries older than the configured retention period. The default retention is 90 days for standard editions. Enterprise editions can configure up to 7 years. GDPR data subject erasure requests will remove entity-level data but retain the audit log entries with payload data anonymized (user details replaced with REDACTED).