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.

Core.Application/Behaviors/AuditBehavior.csC#
// 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.

Identity.Domain/Entities/AuditLog.csC#
// 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;
}
Identity.Infrastructure/Persistence/EntityConfigurations/AuditLogConfiguration.csC#
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 ParameterTypeDescription
entityIdstring (encrypted)Filter by entity affected
commandNamestringFilter by CQRS command class name
userIdstring (encrypted)Filter by acting user
tenantIdstring (encrypted)Filter by tenant (super-admin only)
fromISO 8601 dateCreatedAt >= from
toISO 8601 dateCreatedAt <= to
pageint (default: 1)Pagination page number
pageSizeint (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).