Audit Trail
Essentials + Compliance
This page covers two related features. The default ITayraAuditLogger (observability-grade structured logging) ships with Essentials. The persistent hash-chained audit trail (durable, queryable, tamper-evident) ships with Compliance - covered in the "Persistent Hash-Chained Audit Trail" section below. See Licensing for the edition comparison.
Tayra provides a built-in audit trail system for GDPR Article 30 compliance. Every encryption, decryption, key creation, key deletion, and crypto-shredding operation is logged as a structured audit event. This gives you a complete record of all processing activities involving personal data encryption keys.
GDPR Article 30 - Records of Processing Activities
Article 30 of the GDPR requires organizations to maintain records of processing activities that involve personal data. Tayra's audit trail satisfies this requirement by recording:
- What happened (event type)
- When it happened (timestamp)
- Which key was involved (key ID)
- Whose data was affected (subject ID)
- What entity type was processed (entity type)
- How many fields were involved (field count)
Setup
The DefaultTayraAuditLogger is registered automatically when you call AddTayra(). It writes structured log entries via ILogger and emits ActivityEvent entries on the current OpenTelemetry activity:
var auditServices = new ServiceCollection();
auditServices.AddLogging();
auditServices.AddTayra(opts => opts.LicenseKey = licenseKey);
// The DefaultTayraAuditLogger is registered automatically by AddTayra().
// It logs structured audit events via ILogger and emits ActivityEvents.
// To replace it with a custom implementation:
auditServices.AddSingleton<ITayraAuditLogger, ConsoleAuditLogger>();To disable audit logging, register the NullTayraAuditLogger:
services.AddSingleton<ITayraAuditLogger>(
Tayra.Audit.NullTayraAuditLogger.Instance);Custom Audit Logger
Implement the ITayraAuditLogger interface to send audit events to your compliance system:
/// <summary>
/// Custom audit logger that writes events to the console.
/// In production, you might write to a database, message queue,
/// or external audit system (e.g., Splunk, Datadog, Azure Monitor).
/// </summary>
public class ConsoleAuditLogger : ITayraAuditLogger
{
public void LogEvent(TayraAuditEvent auditEvent)
{
Console.WriteLine(
$" [AUDIT] {auditEvent.Timestamp:O} {auditEvent.EventType} " +
$"KeyId={auditEvent.KeyId} SubjectId={auditEvent.SubjectId} " +
$"EntityType={auditEvent.EntityType} FieldCount={auditEvent.FieldCount}");
}
}The ITayraAuditLogger interface has a single synchronous method:
public interface ITayraAuditLogger
{
void LogEvent(TayraAuditEvent auditEvent);
}Synchronous by Design
The LogEvent method is synchronous to avoid adding async overhead to every encryption operation. Implementations should be fast and non-blocking. If you need to write to a database or external service, buffer events in memory and flush asynchronously (e.g., using a Channel<T> or background service).
Audit Event Model
The TayraAuditEvent record contains all the context for a single audit entry:
// TayraAuditEvent is a record with the following properties:
var exampleEvent = new TayraAuditEvent
{
EventType = TayraAuditEventType.DataEncrypted,
Timestamp = DateTimeOffset.UtcNow, // Auto-set to UtcNow
KeyId = "patient-abc123", // The encryption key involved
SubjectId = "abc123", // The data subject identifier
EntityType = "PatientRecord", // The .NET type being processed
FieldCount = 3, // Number of PII fields encrypted
Details = "Encrypted during save", // Free-text details
};
Console.WriteLine($" EventType: {exampleEvent.EventType}");
Console.WriteLine($" Timestamp: {exampleEvent.Timestamp}");
Console.WriteLine($" KeyId: {exampleEvent.KeyId}");
Console.WriteLine($" SubjectId: {exampleEvent.SubjectId}");
Console.WriteLine($" EntityType: {exampleEvent.EntityType}");
Console.WriteLine($" FieldCount: {exampleEvent.FieldCount}");
Console.WriteLine($" Details: {exampleEvent.Details}");Properties
| Property | Type | Description |
|---|---|---|
EventType | TayraAuditEventType | The type of operation that occurred (required) |
Timestamp | DateTimeOffset | When the event occurred (defaults to UtcNow) |
KeyId | string? | The encryption key ID involved, if applicable |
SubjectId | string? | The data subject identifier, if applicable |
EntityType | string? | The .NET type name of the entity being processed |
FieldCount | int? | The number of PII fields encrypted or decrypted |
Details | string? | Free-text additional details |
Event Types
Tayra emits 16 distinct audit event types covering the full lifecycle of data protection operations:
// All audit event types emitted by Tayra:
var allEventTypes = Enum.GetValues<TayraAuditEventType>();
foreach (var eventType in allEventTypes)
{
Console.WriteLine($" {eventType}");
}
// Output:
// KeyCreated
// KeyAccessed
// KeyDeleted
// BulkKeysDeleted
// DataEncrypted
// DataDecrypted
// CryptoShreddingDetected
// KeyExpired
// DataSubjectAccessExported
// DataSubjectPortableExported
// BreachAssessed
// BreachNotificationGenerated
// DataMigrationCompleted
// DataMigrationVerifiedComplete Event Type Reference
| Event Type | Emitted By | Description |
|---|---|---|
KeyCreated | DefaultCryptoEngine.GetOrCreateKeyAsync, RotateKeyAsync | A new encryption key was generated and stored |
KeyAccessed | DefaultCryptoEngine.GetOrCreateKeyAsync, GetKeyAsync | An encryption key was retrieved (from cache or store) |
KeyDeleted | DefaultCryptoEngine.DeleteKeyAsync | A single encryption key was deleted (crypto-shredding) |
BulkKeysDeleted | DefaultCryptoEngine.DeleteAllKeysAsync | All keys matching a prefix were deleted |
DataEncrypted | DefaultFieldEncrypter.EncryptAsync | PII fields on an entity were encrypted |
DataDecrypted | DefaultFieldEncrypter.DecryptAsync | PII fields on an entity were decrypted |
CryptoShreddingDetected | DefaultFieldEncrypter (during decrypt) | Decryption was attempted but the key was already shredded |
KeyExpired | KeyRetentionBackgroundService | A key was deleted by the retention policy |
DataSubjectAccessExported | DefaultDataSubjectAccessService | A GDPR Art. 15 data subject access report was generated |
DataSubjectPortableExported | DefaultDataSubjectAccessService | A GDPR Art. 20 data portability export was generated |
BreachAssessed | DefaultBreachNotificationService | A data breach impact assessment was performed |
BreachNotificationGenerated | DefaultBreachNotificationService | A breach notification report was generated |
DataMigrationCompleted | Data migration service | A data migration batch was completed |
DataMigrationVerified | Data migration service | A data migration was verified |
BlindIndexRecomputed | Blind index recompute services (Marten, EF Core) | Blind index companion columns were recomputed after an HMAC key rotation or transform change |
IntegrityCheckFailed | DefaultFieldEncrypter (during decrypt) | Decryption failed authentication - the ciphertext was tampered with or its associated-data context does not match. Also logged at Warning level; the field value is left unchanged |
Default Logger Behavior
The DefaultTayraAuditLogger performs two actions for each event:
1. Structured Logging via ILogger
Events are logged at Information level with structured properties:
Tayra audit: DataEncrypted KeyId=patient-abc123 SubjectId=abc123 EntityType=PatientRecord FieldCount=3 Details=nullThis integrates with any ILogger provider (Console, Serilog, Application Insights, etc.) and supports structured log queries.
2. OpenTelemetry ActivityEvents
If there is a current Activity (distributed trace), the logger adds an ActivityEvent named "tayra.audit" with tags:
| Tag | Description |
|---|---|
tayra.audit.event_type | The event type as a string |
tayra.audit.key_id | The key ID (if present) |
tayra.audit.subject_id | The subject ID (if present) |
tayra.audit.entity_type | The entity type (if present) |
tayra.audit.field_count | The field count (if present) |
These appear as span events in your distributed tracing backend (Jaeger, Zipkin, Grafana Tempo), giving you a unified view of audit events within request traces.
Compliance Storage
The default logger writes to ILogger, which is typically ephemeral (console, rolling files). For GDPR compliance, either configure a persistent log sink (database, immutable object store, Splunk, Azure Monitor) or - much better - switch to the persistent hash-chained audit trail described below, which Tayra ships out of the box with the Compliance edition.
Persistent Hash-Chained Audit Trail (Compliance edition)
The Compliance edition adds a durable, queryable, tamper-evident audit trail backed by an IAuditTrailStore. Every event flows through a hash chain so an auditor can later prove the trail has not been modified since events were recorded.
What's different from the default logger
Default DefaultTayraAuditLogger (Essentials) | PersistedAuditLogger (Compliance) | |
|---|---|---|
| Storage | ILogger + OpenTelemetry breadcrumbs | Durable IAuditTrailStore |
| Queryable per-subject | No (depends on log backend) | Yes, first-class QueryAsync API |
| Tamper-evident | No | Yes, hash chain detects modification, deletion, reorder, or fabrication |
| Integrity verification | n/a | VerifyIntegrityAsync walks the chain and reports the first failure |
| Cross-process retention | Whatever your log backend retains | Persisted alongside Marten documents (or your own IAuditTrailStore) |
Hash chain construction
Each AuditTrailEntry records:
SequenceNumber- monotonic, gap-detectableEvent- theTayraAuditEventitselfPreviousHash- hex SHA-256 of the previous entry (empty for the genesis entry)EntryHash- hex SHA-256 of(sequence || canonical-event-json || previous-hash), length-prefixed to defeat concatenation collisions
Modifying any committed event invalidates the corresponding EntryHash and every subsequent hash. Reordering breaks the SequenceNumber invariant. Deletion creates a detectable gap. Fabricated entries break the hash chain.
Wiring the in-memory store (tests, demos)
services.AddTayra(opts => opts.LicenseKey = key);
services.AddTayraInMemoryAuditTrail();The chain lives only as long as the host process - perfect for unit tests and one-off demos.
Wiring the Marten-backed store (production)
services.AddMarten(opts =>
{
opts.Connection(connectionString);
opts.AutoCreateSchemaObjects = AutoCreate.All;
}).UseLightweightSessions();
services.AddTayraMartenAuditTrail();Each appended event is persisted as a MartenAuditTrailDocument in PostgreSQL with full Marten LINQ + indexing support. Append operations serialize through a per-store SemaphoreSlim so sequence numbers and the chain stay consistent under concurrent writers within a process. Production deployments should either run a single audit-writer instance per Marten store or layer Wolverine outbox / message-queue ordering on top of AppendAsync for safe multi-writer support.
Querying the trail
var store = serviceProvider.GetRequiredService<IAuditTrailStore>();
var alice = await store.QueryAsync(new AuditTrailQuery
{
SubjectId = "cust-42",
From = DateTimeOffset.UtcNow.AddDays(-365),
Take = 1000,
});Filter by SubjectId, KeyId, EventType, time window, plus Skip / Take pagination. Results are ordered by SequenceNumber ascending.
Verifying integrity
Run VerifyIntegrityAsync periodically (CI gate, monthly compliance task, ad hoc on regulator request):
var result = await store.VerifyIntegrityAsync();
if (!result.IsValid)
{
logger.LogCritical(
"Audit trail tampered: failed at sequence {Seq} ({Reason})",
result.FailedAtSequenceNumber, result.Reason);
}A clean result returns IsValid = true and the entry count. A failure returns the first sequence where the chain breaks plus a human-readable reason.
Custom backends
Implement IAuditTrailStore to back the trail with anything (EF Core, plain Postgres, S3 + WORM bucket, etc). Reuse AuditChainHasher.ComputeEntryHash so your store hashes events the same way as the in-memory and Marten backends - chains then verify correctly across stores in mixed deployments.
See Also
- Observability - Distributed tracing and metrics
- Configuration - Service registration
- Getting Started - Core encrypt/decrypt/shred workflow
