Immutable audit logging patterns for compliance and security. Covers event design, storage strategies, retention policies, and audit trail analysis.
Provides immutable audit logging patterns for compliance and security. Use when designing audit systems to implement event schemas, tamper-evident storage, retention policies, and audit trail analysis.
/plugin marketplace add melodic-software/claude-code-plugins/plugin install saas-patterns@melodic-softwareThis skill is limited to using the following tools:
Patterns for implementing immutable audit logs that meet compliance requirements and enable security analysis.
Use this skill when:
Audit logs provide a tamper-evident record of who did what, when, and from where. They are essential for compliance (SOC 2, HIPAA, GDPR), security investigations, and operational troubleshooting.
+------------------------------------------------------------------+
| Audit Logging Pipeline |
+------------------------------------------------------------------+
| |
| +-------------+ +---------------+ +--------------------+ |
| | Application | | Audit Service | | Immutable Storage | |
| | (emit) |--->| (enrich, |--->| (append-only, | |
| | | | validate) | | tamper-evident) | |
| +-------------+ +---------------+ +--------------------+ |
| | | | |
| v v v |
| User actions Timestamp, WORM storage, |
| System events correlation, blockchain hash, |
| Data changes tenant context retention lock |
| |
+------------------------------------------------------------------+
public sealed record AuditEvent
{
// Identity
public required Guid EventId { get; init; } = Guid.NewGuid();
public required DateTimeOffset Timestamp { get; init; }
public required string EventType { get; init; } // "user.login", "data.export", etc.
// Actor
public required Guid? UserId { get; init; }
public required Guid TenantId { get; init; }
public string? UserEmail { get; init; }
public string? UserRole { get; init; }
public required ActorType ActorType { get; init; } // User, System, API, Service
// Context
public required string Source { get; init; } // "web", "api", "background-job"
public string? IpAddress { get; init; }
public string? UserAgent { get; init; }
public string? SessionId { get; init; }
public string? CorrelationId { get; init; }
public string? RequestId { get; init; }
// Action
public required string Action { get; init; } // "create", "read", "update", "delete"
public required string ResourceType { get; init; } // "user", "invoice", "settings"
public string? ResourceId { get; init; }
public required bool Success { get; init; }
public string? FailureReason { get; init; }
// Change Details (for modifications)
public Dictionary<string, object?>? OldValues { get; init; }
public Dictionary<string, object?>? NewValues { get; init; }
// Metadata
public Dictionary<string, string>? Tags { get; init; }
}
public enum ActorType
{
User,
System,
ApiClient,
BackgroundService,
ExternalIntegration
}
Event Type Hierarchy:
+------------------------------------------------------------------+
| Category | Event Types |
+-------------------+----------------------------------------------+
| Authentication | user.login, user.logout, user.login_failed, |
| | user.mfa_enabled, user.password_changed |
+-------------------+----------------------------------------------+
| Authorization | access.granted, access.denied, |
| | permission.changed, role.assigned |
+-------------------+----------------------------------------------+
| Data Operations | data.created, data.updated, data.deleted, |
| | data.exported, data.imported |
+-------------------+----------------------------------------------+
| Administration | settings.changed, user.invited, |
| | integration.enabled, api_key.created |
+-------------------+----------------------------------------------+
| Billing | subscription.created, payment.processed, |
| | invoice.generated, plan.changed |
+-------------------+----------------------------------------------+
| Security | security.alert, ip.blocked, |
| | suspicious.activity, breach.detected |
+-------------------+----------------------------------------------+
public interface IAuditService
{
// Log an audit event
Task LogAsync(AuditEvent auditEvent, CancellationToken ct = default);
// Log with builder pattern
Task LogAsync(Action<AuditEventBuilder> configure, CancellationToken ct = default);
// Query audit log (for admin/compliance)
Task<PagedResult<AuditEvent>> QueryAsync(
AuditQuery query,
CancellationToken ct = default);
// Export for compliance reports
Task<Stream> ExportAsync(
AuditExportRequest request,
CancellationToken ct = default);
}
public sealed class AuditEventBuilder
{
private readonly AuditEvent _event = new();
public AuditEventBuilder WithUser(Guid userId, string? email = null)
{
_event = _event with { UserId = userId, UserEmail = email };
return this;
}
public AuditEventBuilder WithAction(string eventType, string action, bool success = true)
{
_event = _event with { EventType = eventType, Action = action, Success = success };
return this;
}
public AuditEventBuilder WithResource(string resourceType, string? resourceId)
{
_event = _event with { ResourceType = resourceType, ResourceId = resourceId };
return this;
}
public AuditEventBuilder WithChanges(object? oldValue, object? newValue)
{
// Serialize to dictionaries, redacting sensitive fields
return this;
}
public AuditEvent Build() => _event;
}
// EF Core interceptor for automatic data change auditing
public sealed class AuditSaveChangesInterceptor(
IAuditService auditService,
ITenantContext tenantContext,
ICurrentUserService currentUser) : SaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var context = eventData.Context;
if (context is null) return result;
var auditableEntries = context.ChangeTracker
.Entries()
.Where(e => e.Entity is IAuditable)
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.ToList();
foreach (var entry in auditableEntries)
{
var auditEvent = new AuditEvent
{
EventId = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
EventType = $"data.{entry.State.ToString().ToLowerInvariant()}",
UserId = currentUser.UserId,
TenantId = tenantContext.TenantId,
ActorType = ActorType.User,
Source = "api",
Action = entry.State.ToString().ToLowerInvariant(),
ResourceType = entry.Entity.GetType().Name,
ResourceId = GetPrimaryKey(entry),
Success = true,
OldValues = entry.State != EntityState.Added
? GetValues(entry.OriginalValues)
: null,
NewValues = entry.State != EntityState.Deleted
? GetValues(entry.CurrentValues)
: null
};
await auditService.LogAsync(auditEvent, ct);
}
return result;
}
}
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class AuditAttribute : Attribute, IAsyncActionFilter
{
public string EventType { get; set; } = "api.request";
public string? ResourceType { get; set; }
public bool LogRequestBody { get; set; }
public bool LogResponseBody { get; set; }
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var auditService = context.HttpContext.RequestServices
.GetRequiredService<IAuditService>();
var startTime = DateTimeOffset.UtcNow;
var result = await next();
var auditEvent = new AuditEvent
{
EventType = EventType,
Timestamp = startTime,
Action = context.HttpContext.Request.Method,
ResourceType = ResourceType ?? context.Controller.GetType().Name,
ResourceId = context.ActionArguments.TryGetValue("id", out var id)
? id?.ToString()
: null,
Success = result.Exception is null,
FailureReason = result.Exception?.Message,
// ... populate from HttpContext
};
await auditService.LogAsync(auditEvent);
}
}
Immutability Options:
+------------------------------------------------------------------+
| Strategy | How It Works |
+-----------------------+------------------------------------------+
| Append-only table | No UPDATE/DELETE permissions on table |
| WORM storage | Azure Immutable Blob, AWS S3 Object Lock |
| Blockchain hash | Each entry includes hash of previous |
| Digital signatures | Sign entries with HSM-backed key |
| Write-ahead log | Append to log, never modify |
+------------------------------------------------------------------+
-- Append-only audit table
CREATE TABLE audit_logs (
event_id UUID PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
event_type VARCHAR(100) NOT NULL,
tenant_id UUID NOT NULL,
user_id UUID,
actor_type VARCHAR(50) NOT NULL,
source VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
resource_type VARCHAR(100) NOT NULL,
resource_id VARCHAR(255),
success BOOLEAN NOT NULL,
failure_reason TEXT,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
session_id VARCHAR(255),
correlation_id VARCHAR(255),
tags JSONB,
-- Hash chain for tamper detection
previous_hash VARCHAR(64),
entry_hash VARCHAR(64) NOT NULL
);
-- Partitioning for performance and retention
CREATE TABLE audit_logs_y2024m01 PARTITION OF audit_logs
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
-- Indexes for common queries
CREATE INDEX idx_audit_tenant_time ON audit_logs (tenant_id, timestamp DESC);
CREATE INDEX idx_audit_user_time ON audit_logs (user_id, timestamp DESC);
CREATE INDEX idx_audit_resource ON audit_logs (resource_type, resource_id);
CREATE INDEX idx_audit_event_type ON audit_logs (event_type);
-- Revoke modification permissions
REVOKE UPDATE, DELETE ON audit_logs FROM app_user;
public sealed class HashChainAuditStore(IDbContext db)
{
public async Task AppendAsync(AuditEvent auditEvent, CancellationToken ct)
{
// Get hash of previous entry
var previousHash = await db.AuditLogs
.Where(a => a.TenantId == auditEvent.TenantId)
.OrderByDescending(a => a.Timestamp)
.Select(a => a.EntryHash)
.FirstOrDefaultAsync(ct);
// Compute hash of current entry including previous hash
var entryHash = ComputeHash(auditEvent, previousHash);
var logEntry = new AuditLogEntry
{
// ... map from auditEvent
PreviousHash = previousHash,
EntryHash = entryHash
};
db.AuditLogs.Add(logEntry);
await db.SaveChangesAsync(ct);
}
private static string ComputeHash(AuditEvent entry, string? previousHash)
{
var data = JsonSerializer.Serialize(entry) + (previousHash ?? "");
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(data));
return Convert.ToHexString(hash);
}
public async Task<bool> VerifyChainIntegrityAsync(
Guid tenantId,
DateTimeOffset start,
DateTimeOffset end,
CancellationToken ct)
{
var entries = await db.AuditLogs
.Where(a => a.TenantId == tenantId)
.Where(a => a.Timestamp >= start && a.Timestamp <= end)
.OrderBy(a => a.Timestamp)
.ToListAsync(ct);
string? previousHash = null;
foreach (var entry in entries)
{
var expectedHash = ComputeHash(entry.ToAuditEvent(), previousHash);
if (entry.EntryHash != expectedHash)
return false; // Tampering detected
previousHash = entry.EntryHash;
}
return true;
}
}
Retention Requirements:
+------------------------------------------------------------------+
| Framework | Minimum Retention | Notes |
+----------------+-------------------+------------------------------+
| SOC 2 | 1 year | Auditor access required |
| HIPAA | 6 years | From creation or last use |
| GDPR | As needed | Minimize, but prove actions |
| PCI DSS | 1 year | 3 months immediately online |
| SOX | 7 years | Financial records |
| General | 3-7 years | Legal/litigation hold |
+------------------------------------------------------------------+
public sealed class AuditRetentionService(
IDbContext db,
IAuditArchiveStore archiveStore,
ILogger<AuditRetentionService> logger)
{
public async Task EnforceRetentionAsync(CancellationToken ct)
{
var policies = await GetRetentionPoliciesAsync(ct);
foreach (var policy in policies)
{
// Archive before delete
if (policy.ArchiveBeforeDelete)
{
var toArchive = await db.AuditLogs
.Where(a => a.EventType.StartsWith(policy.EventTypePrefix))
.Where(a => a.Timestamp < DateTimeOffset.UtcNow - policy.OnlineRetention)
.Where(a => a.Timestamp >= DateTimeOffset.UtcNow - policy.TotalRetention)
.ToListAsync(ct);
await archiveStore.ArchiveAsync(toArchive, ct);
}
// Delete expired
var deleted = await db.AuditLogs
.Where(a => a.EventType.StartsWith(policy.EventTypePrefix))
.Where(a => a.Timestamp < DateTimeOffset.UtcNow - policy.TotalRetention)
.Where(a => !a.HasLegalHold)
.ExecuteDeleteAsync(ct);
logger.LogInformation(
"Deleted {Count} audit logs for policy {Policy}",
deleted, policy.Name);
}
}
}
public sealed class AuditRedactor
{
private static readonly HashSet<string> SensitiveFields = new(StringComparer.OrdinalIgnoreCase)
{
"password", "secret", "token", "apikey", "api_key",
"ssn", "social_security", "credit_card", "cvv",
"bank_account", "routing_number"
};
public Dictionary<string, object?> Redact(Dictionary<string, object?> values)
{
var redacted = new Dictionary<string, object?>();
foreach (var (key, value) in values)
{
if (SensitiveFields.Contains(key))
{
redacted[key] = "[REDACTED]";
}
else if (value is Dictionary<string, object?> nested)
{
redacted[key] = Redact(nested);
}
else
{
redacted[key] = value;
}
}
return redacted;
}
}
public sealed class AuditQueryService(IDbContext db)
{
// Security: Failed login attempts
public async Task<IReadOnlyList<AuditEvent>> GetFailedLoginsAsync(
Guid tenantId,
TimeSpan window,
CancellationToken ct)
{
return await db.AuditLogs
.Where(a => a.TenantId == tenantId)
.Where(a => a.EventType == "user.login_failed")
.Where(a => a.Timestamp > DateTimeOffset.UtcNow - window)
.OrderByDescending(a => a.Timestamp)
.Take(100)
.ToListAsync(ct);
}
// Compliance: All actions by user
public async Task<PagedResult<AuditEvent>> GetUserActivityAsync(
Guid tenantId,
Guid userId,
DateTimeOffset start,
DateTimeOffset end,
int page,
int pageSize,
CancellationToken ct)
{
var query = db.AuditLogs
.Where(a => a.TenantId == tenantId)
.Where(a => a.UserId == userId)
.Where(a => a.Timestamp >= start && a.Timestamp <= end);
var total = await query.CountAsync(ct);
var items = await query
.OrderByDescending(a => a.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return new PagedResult<AuditEvent>(items, total, page, pageSize);
}
// Data access: Who accessed this resource
public async Task<IReadOnlyList<AuditEvent>> GetResourceAccessAsync(
string resourceType,
string resourceId,
TimeSpan window,
CancellationToken ct)
{
return await db.AuditLogs
.Where(a => a.ResourceType == resourceType)
.Where(a => a.ResourceId == resourceId)
.Where(a => a.Timestamp > DateTimeOffset.UtcNow - window)
.OrderByDescending(a => a.Timestamp)
.ToListAsync(ct);
}
}
Audit Logging Best Practices:
+------------------------------------------------------------------+
| Practice | Rationale |
+---------------------------+--------------------------------------+
| Log synchronously | Ensure log before action completes |
| Include context | Who, what, when, where, why |
| Use structured format | Enables analysis and alerting |
| Redact sensitive data | PII, credentials, PHI protection |
| Partition by time | Performance and retention management |
| Hash chain for integrity | Tamper detection |
| Separate storage | Isolation from app DB |
| Real-time alerting | Security event detection |
+------------------------------------------------------------------+
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Log after action | May miss failures | Log before/during action |
| Mutable storage | Tampering possible | Append-only, WORM storage |
| No tenant isolation | Data leakage | Include tenant in all queries |
| Plain text secrets | Compliance violation | Always redact sensitive fields |
| Single partition | Performance degrades | Time-based partitioning |
| No retention policy | Storage costs, legal risk | Define and enforce policies |
Load for detailed implementation:
references/immutable-logs.md - Storage patterns for immutabilityreferences/retention-policies.md - Compliance-driven retentionsaas-compliance-frameworks - Compliance requirementstenant-data-isolation - Multi-tenant audit separationFor current audit logging patterns:
perplexity: "audit logging compliance 2024" "immutable audit trail patterns"
microsoft-learn: "Azure Monitor audit" "Application Insights audit logging"
Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.