Audit Logging
This guide covers the audit logging system in Excalibur.Dispatch, including configuration, hash chain integrity, SIEM integration, and compliance features.
Before You Start
- .NET 10.0
- Install the required packages:
dotnet add package Excalibur.AuditLoggingdotnet add package Excalibur.AuditLogging.SqlServer # or your provider
- Familiarity with security concepts and compliance requirements
Overview
The audit logging system provides a hash-chained, tamper-evident audit trail for compliance and security monitoring:
| Capability | Description | Compliance |
|---|---|---|
| Hash Chain Integrity | Each event hashed with previous event's hash | SOC 2 CC6.1 |
| Role-Based Access | Segregation of duties for audit log access | SOC 2 CC6.2 |
| SIEM Integration | Export to Splunk, Sentinel, Datadog | SOC 2 CC7.1 |
| 7-Year Retention | Long-term compliance storage | SOC 2 CC7.2 |
Installation
# Core package
dotnet add package Excalibur.AuditLogging
# Storage providers
dotnet add package Excalibur.AuditLogging.SqlServer
# SIEM exporters
dotnet add package Excalibur.AuditLogging.Splunk
dotnet add package Excalibur.AuditLogging.Sentinel
dotnet add package Excalibur.AuditLogging.Datadog
Quick Start
Basic Configuration
using Excalibur.AuditLogging.SqlServer;
var builder = WebApplication.CreateBuilder(args);
// Add audit logging with SQL Server storage
builder.Services.AddAuditLogging();
builder.Services.AddSqlServerAuditStore();
builder.Services.Configure<SqlServerAuditOptions>(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("AuditDb")!;
options.EnableHashChain = true; // Tamper detection
// Retention is a nested group on SqlServerAuditOptions, not a flat property
options.Retention.RetentionPeriod = TimeSpan.FromDays(7 * 365); // 7 years
});
Logging Audit Events
public class OrderService
{
private readonly IAuditLogger _auditLogger;
public OrderService(IAuditLogger auditLogger)
{
_auditLogger = auditLogger;
}
public async Task<Order> CreateOrderAsync(CreateOrderRequest request, string userId)
{
var order = await ProcessOrder(request);
await _auditLogger.LogAsync(new AuditEvent
{
EventId = Guid.NewGuid().ToString("N"),
EventType = AuditEventType.DataModification,
Action = "Order.Create",
Outcome = AuditOutcome.Success,
Timestamp = DateTimeOffset.UtcNow,
ActorId = userId,
ResourceId = order.Id.ToString(),
ResourceType = "Order",
ResourceClassification = DataClassification.Confidential,
TenantId = request.TenantId,
CorrelationId = Activity.Current?.Id,
Metadata = new Dictionary<string, string>
{
["order_total"] = order.Total.ToString("C"),
["item_count"] = order.Items.Count.ToString()
}
});
return order;
}
}
Failure Handling: Fail-Closed Persistence
IAuditLogger.LogAsync is fail-closed. If the underlying audit store cannot durably persist the event, the failure is surfaced as an AuditPersistenceException (namespace Excalibur.Compliance) rather than being masked behind a success-shaped result. A returned AuditEventId therefore always denotes a durably recorded event — never a dropped one.
A compliance audit trail that silently drops events is worse than one that fails loudly, because callers would otherwise treat an unrecorded event as durably stored. LogAsync no longer swallows store failures. Code that calls LogAsync and ignores the outcome will now propagate AuditPersistenceException when the store is unavailable.
The default behavior — let the exception propagate — is the correct, safe choice for most compliance scenarios: if the audit cannot be recorded, the operation it audits should not be reported as complete.
If your scenario requires availability over durability (fail-open), catch the exception explicitly and apply your own policy, such as queuing the event for later retry:
using Excalibur.Compliance;
try
{
await _auditLogger.LogAsync(auditEvent, cancellationToken);
}
catch (AuditPersistenceException ex)
{
// Fail-open: the audit store is unavailable. Apply your own durability policy
// instead of dropping the event silently. ex.EventId identifies the unsaved event.
_logger.LogError(ex, "Audit event {EventId} could not be persisted; queuing for retry.", ex.EventId);
await _auditRetryQueue.EnqueueAsync(auditEvent, cancellationToken);
}
Genuine cancellation is not wrapped: when the supplied CancellationToken is cancelled, LogAsync rethrows OperationCanceledException directly rather than an AuditPersistenceException.
Audit Event Structure
Event Types
public enum AuditEventType
{
System = 0, // General system events
Authentication = 1, // Login, logout, MFA
Authorization = 2, // Permission checks, access grants
DataAccess = 3, // Read, query operations
DataModification = 4, // Create, update, delete
ConfigurationChange = 5, // Configuration updates
Security = 6, // Key rotation, encryption
Compliance = 7, // Data export, erasure requests
Administrative = 8, // Admin actions
Integration = 9 // API calls, external systems
}
Outcomes
public enum AuditOutcome
{
Success = 0, // Operation completed successfully
Failure = 1, // Operation failed
Denied = 2, // Authorization denied
Error = 3, // Error occurred
Pending = 4 // In progress
}
Event Properties
| Property | Type | Required | Description |
|---|---|---|---|
EventId | string | Yes | Unique identifier |
EventType | AuditEventType | Yes | Category of event |
Action | string | Yes | Action performed (e.g., "User.Login") |
Outcome | AuditOutcome | Yes | Result of operation |
Timestamp | DateTimeOffset | Yes | When event occurred |
ActorId | string | Yes | Who performed action |
ResourceId | string | No | Target resource identifier |
ResourceType | string | No | Type of resource |
TenantId | string | No | Tenant identifier |
ApplicationName | string | No | Application that produced the event |
CorrelationId | string | No | Distributed tracing ID |
PreviousEventHash | string | Auto | Hash of previous event |
EventHash | string | Auto | Hash of this event |
Hash Chain Integrity
How It Works
Each audit event is cryptographically linked to the previous event:
Event 1: Hash = SHA256(EventData)
Event 2: Hash = SHA256(EventData + Event1.Hash)
Event 3: Hash = SHA256(EventData + Event2.Hash)
...
This creates a tamper-evident chain where modifying any event breaks the chain.
Verify Integrity
var auditLogger = serviceProvider.GetRequiredService<IAuditLogger>();
var result = await auditLogger.VerifyIntegrityAsync(
startDate: DateTimeOffset.UtcNow.AddMonths(-1),
endDate: DateTimeOffset.UtcNow,
cancellationToken);
switch (result.Outcome)
{
case AuditIntegrityOutcome.Verified:
Console.WriteLine($"Verified {result.EventsVerified} events - integrity OK");
break;
case AuditIntegrityOutcome.ViolationsDetected:
Console.WriteLine($"INTEGRITY VIOLATION: {result.CompromisedChainCount} compromised chain(s)");
Console.WriteLine($"First violation: {result.FirstViolationEventId}");
Console.WriteLine($"Details: {result.ViolationDescription}");
break;
case AuditIntegrityOutcome.NoEventsInScope:
Console.WriteLine("No audit events in this window - integrity was not checked");
break;
}
Verification has three outcomes, not two. When the requested window contains no audit events, the
store reports NoEventsInScope: it examined nothing, so nothing about the integrity of your audit
log follows from the run. Never fold that case in with Verified when producing compliance
evidence — an unexamined period is not evidence that the log is intact. A window you expected to
contain events but that reports NoEventsInScope may mean audit events are not reaching the store
at all.
Integrity Result Properties
| Property | Type | Description |
|---|---|---|
Outcome | AuditIntegrityOutcome | Verified, ViolationsDetected, or NoEventsInScope |
EventsVerified | long | Number of events checked (always 0 for NoEventsInScope) |
CompromisedChainCount | int | Number of the store's chaining units that failed. Each compromised unit counts once however many of its records are affected. With IsHashChained false the store chains nothing and each record is its own unit, so this counts records instead. |
IsHashChained | bool | Whether the records were hash-chained. false means only each record's own content integrity was tested — deletion, insertion and reordering were not. |
FirstViolationEventId | string? | ID of the first corrupted event |
ViolationDescription | string? | Detailed validation message |
StartDate | DateTimeOffset | Start of the verification range |
EndDate | DateTimeOffset | End of the verification range |
VerifiedAt | DateTimeOffset | When verification was performed |
Compliance Mapping
SOC 2 Controls
| Control | Requirement | Dispatch Feature |
|---|---|---|
| CC4.1 | Monitor system components | Automatic dispatch/handler logging |
| CC4.2 | Evaluate security events | AuditEventType categorization |
| CC6.1 | Logical access security | Hash chain integrity |
| CC6.2 | Access restriction | Role-based audit access |
| CC7.1 | Security event identification | Event type taxonomy |
| CC7.2 | Security event response | 7-year retention, SIEM export |
GDPR Article 30
| Requirement | Implementation |
|---|---|
| Processing activities | AuditEventType.DataAccess, DataModification |
| Categories of data subjects | ResourceType field |
| Categories of recipients | ActorId, ActorType fields |
| Time limits for erasure | RetentionPeriod configuration |
| Security measures | Hash chain, RBAC, encryption |
HIPAA 164.312(b)
| Requirement | Implementation |
|---|---|
| Audit controls | Automatic handler/dispatch logging |
| Record access | DataAccess event type |
| Record modification | DataModification event type |
| Activity review | Query API, SIEM integration |
SIEM Integration
Splunk (HTTP Event Collector)
using Excalibur.AuditLogging.Splunk;
builder.Services.AddHttpClient<SplunkAuditExporter>();
builder.Services.AddSingleton<IAuditLogExporter, SplunkAuditExporter>();
builder.Services.Configure<SplunkExporterOptions>(options =>
{
options.Connection.HecEndpoint = new Uri("https://splunk.example.com:8088/services/collector");
options.Connection.HecToken = builder.Configuration["Splunk:HecToken"]!;
options.Index = "audit";
options.SourceType = "audit:dispatch";
options.Source = "my-application";
options.Batch.MaxBatchSize = 100;
options.Connection.EnableCompression = true;
});
Azure Sentinel (Log Analytics)
using Excalibur.AuditLogging.Sentinel;
builder.Services.AddHttpClient<SentinelAuditExporter>();
builder.Services.AddSingleton<IAuditLogExporter, SentinelAuditExporter>();
builder.Services.Configure<SentinelExporterOptions>(options =>
{
options.WorkspaceId = builder.Configuration["Sentinel:WorkspaceId"]!;
options.SharedKey = builder.Configuration["Sentinel:SharedKey"]!;
options.LogType = "DispatchAudit";
options.TimeGeneratedField = "timestamp";
options.MaxBatchSize = 100;
});
Datadog (Logs API)
using Excalibur.AuditLogging.Datadog;
builder.Services.AddHttpClient<DatadogAuditExporter>();
builder.Services.AddSingleton<IAuditLogExporter, DatadogAuditExporter>();
builder.Services.Configure<DatadogExporterOptions>(options =>
{
options.ApiKey = builder.Configuration["Datadog:ApiKey"]!;
options.Site = "datadoghq.com";
options.Service = "my-application";
options.Source = "audit";
options.Tags = "env:production,team:platform";
options.UseCompression = true;
});
Export Background Service
public class AuditExportService : BackgroundService
{
private readonly IAuditStore _auditStore;
private readonly IAuditLogExporter _exporter;
private readonly ILogger<AuditExportService> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var query = new AuditQuery
{
StartDate = DateTimeOffset.UtcNow.AddMinutes(-5),
EndDate = DateTimeOffset.UtcNow,
MaxResults = 1000
};
var events = await _auditStore.QueryAsync(query, stoppingToken);
if (events.Count > 0)
{
var result = await _exporter.ExportBatchAsync(events, stoppingToken);
_logger.LogInformation(
"Exported {Success}/{Total} events",
result.SuccessCount, result.TotalCount);
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
Role-Based Access Control
Access Levels
| Role | Access Level | Permissions |
|---|---|---|
| None | 0 | No access |
| Developer | 1 | No access (segregation of duties) |
| SecurityAnalyst | 2 | Read security events only |
| ComplianceOfficer | 3 | Read all events |
| Administrator | 4 | Full access including export |
Configuration
builder.Services.AddAuditLogging()
.AddRbacAuditStore();
builder.Services.AddAuditRoleProvider<ClaimsBasedRoleProvider>();
public class ClaimsBasedRoleProvider : IAuditRoleProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
public Task<AuditLogRole> GetCurrentRoleAsync(CancellationToken ct)
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return Task.FromResult(AuditLogRole.None);
if (user.IsInRole("audit-admin"))
return Task.FromResult(AuditLogRole.Administrator);
if (user.IsInRole("compliance-officer"))
return Task.FromResult(AuditLogRole.ComplianceOfficer);
if (user.IsInRole("security-analyst"))
return Task.FromResult(AuditLogRole.SecurityAnalyst);
return Task.FromResult(AuditLogRole.Developer);
}
}
Querying Audit Events
var auditStore = serviceProvider.GetRequiredService<IAuditStore>();
var query = new AuditQuery
{
// Time range
StartDate = DateTimeOffset.UtcNow.AddDays(-30),
EndDate = DateTimeOffset.UtcNow,
// Event filters
EventTypes = new[] { AuditEventType.Authentication, AuditEventType.Authorization },
Outcomes = new[] { AuditOutcome.Denied, AuditOutcome.Failure },
// Actor/Resource filters
ActorId = "user-123",
ResourceType = "Order",
// Tenant scoping is NOT a query field. Every store resolves the tenant from the
// ambient ITenantContext and adds the predicate itself, so a caller cannot widen
// or forge its scope by editing the query.
// Multi-app
ApplicationName = "OrderService",
// Pagination
MaxResults = 50,
OrderByDescending = true
};
var events = await auditStore.QueryAsync(query, cancellationToken);
var count = await auditStore.CountAsync(query, cancellationToken);
ActorId and IpAddress filters do not survive field encryptionIf you enable UseAuditLogEncryption(), those two fields are encrypted by default, and the cipher is
randomized — two records holding the same actor id hold different ciphertext, so no server-side
comparison can match either. QueryAsync and CountAsync throw NotSupportedException naming the
field and the option, rather than returning an empty list or a zero that reads as "this actor did
nothing". Set AuditEncryptionOptions.EncryptActorId = false to keep the field searchable, at the cost
of storing it in the clear. See encryption costs you the ability to query by that
field.
Best Practices
What to Audit
Always audit:
- Authentication events (login, logout, MFA)
- Authorization failures (access denied)
- Data access to sensitive resources
- Data modifications (create, update, delete)
- Configuration changes
- Key management operations
Include contextual information:
- Correlation IDs for distributed tracing
- Session IDs for user journey tracking
- IP addresses for security analysis
- Data classification levels
Performance
- Use batching for high-volume scenarios
- Enable compression for SIEM export
- Configure retention appropriately (7 years for SOC 2)
- Use partitioning for large volumes (SQL Server Enterprise)
Security
- Separate audit database from application data
- Encrypt connections with TLS
- Restrict access using RBAC
- Enable hash chain for tamper detection
- Secure credentials in Key Vault
Related Documentation
- Security Guide - Comprehensive security hardening
- Encryption Architecture - Data protection and key lifecycle
- Compliance Checklists - Regulatory requirements
See Also
- Compliance Audit Logging — Compliance-focused audit logging requirements and checklists
- Authorization & Audit (A3) — Activity-based authorization, grants, and the A3 audit subsystem
- Production Observability — Monitoring, alerting, and observability patterns for production deployments