Skip to main content

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.AuditLogging
    dotnet 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:

CapabilityDescriptionCompliance
Hash Chain IntegrityEach event hashed with previous event's hashSOC 2 CC6.1
Role-Based AccessSegregation of duties for audit log accessSOC 2 CC6.2
SIEM IntegrationExport to Splunk, Sentinel, DatadogSOC 2 CC7.1
7-Year RetentionLong-term compliance storageSOC 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.

Behavior change

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​

PropertyTypeRequiredDescription
EventIdstringYesUnique identifier
EventTypeAuditEventTypeYesCategory of event
ActionstringYesAction performed (e.g., "User.Login")
OutcomeAuditOutcomeYesResult of operation
TimestampDateTimeOffsetYesWhen event occurred
ActorIdstringYesWho performed action
ResourceIdstringNoTarget resource identifier
ResourceTypestringNoType of resource
TenantIdstringNoTenant identifier
ApplicationNamestringNoApplication that produced the event
CorrelationIdstringNoDistributed tracing ID
PreviousEventHashstringAutoHash of previous event
EventHashstringAutoHash 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;
}
An empty window is not a pass

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​

PropertyTypeDescription
OutcomeAuditIntegrityOutcomeVerified, ViolationsDetected, or NoEventsInScope
EventsVerifiedlongNumber of events checked (always 0 for NoEventsInScope)
CompromisedChainCountintNumber 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.
IsHashChainedboolWhether the records were hash-chained. false means only each record's own content integrity was tested — deletion, insertion and reordering were not.
FirstViolationEventIdstring?ID of the first corrupted event
ViolationDescriptionstring?Detailed validation message
StartDateDateTimeOffsetStart of the verification range
EndDateDateTimeOffsetEnd of the verification range
VerifiedAtDateTimeOffsetWhen verification was performed

Compliance Mapping​

SOC 2 Controls​

ControlRequirementDispatch Feature
CC4.1Monitor system componentsAutomatic dispatch/handler logging
CC4.2Evaluate security eventsAuditEventType categorization
CC6.1Logical access securityHash chain integrity
CC6.2Access restrictionRole-based audit access
CC7.1Security event identificationEvent type taxonomy
CC7.2Security event response7-year retention, SIEM export

GDPR Article 30​

RequirementImplementation
Processing activitiesAuditEventType.DataAccess, DataModification
Categories of data subjectsResourceType field
Categories of recipientsActorId, ActorType fields
Time limits for erasureRetentionPeriod configuration
Security measuresHash chain, RBAC, encryption

HIPAA 164.312(b)​

RequirementImplementation
Audit controlsAutomatic handler/dispatch logging
Record accessDataAccess event type
Record modificationDataModification event type
Activity reviewQuery 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​

RoleAccess LevelPermissions
None0No access
Developer1No access (segregation of duties)
SecurityAnalyst2Read security events only
ComplianceOfficer3Read all events
Administrator4Full 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 encryption

If 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​

  1. Use batching for high-volume scenarios
  2. Enable compression for SIEM export
  3. Configure retention appropriately (7 years for SOC 2)
  4. Use partitioning for large volumes (SQL Server Enterprise)

Security​

  1. Separate audit database from application data
  2. Encrypt connections with TLS
  3. Restrict access using RBAC
  4. Enable hash chain for tamper detection
  5. Secure credentials in Key Vault

See Also​