Skip to content

Audit Log — Tamper-Proof Activity Tracking

Granit.Auditing provides a turnkey audit trail solution for ISO 27001 A.12.4 compliance. It automatically captures entity changes from EF Core’s ChangeTracker and persists them in a hierarchical model: AuditEntry → AuditEntityChange → AuditPropertyChange.

  • DirectoryGranit.Auditing/ Abstractions, domain types, interfaces, options
    • DirectoryGranit.Auditing.EntityFrameworkCore/ Isolated DbContext, interceptor, persistence, cleanup
    • DirectoryGranit.Auditing.Endpoints/ Read-only Minimal API endpoints
PackageRoleDepends on
Granit.AuditingDomain types, enums, IAuditingReader, IAuditingWriter, optionsGranit, Granit.QueryEngine
Granit.Auditing.EntityFrameworkCoreAuditingDbContext, interceptor, Channel persistence, cleanup workerGranit.Auditing, Granit.Persistence
Granit.Auditing.EndpointsGET /audit-log endpoints (read-only, authorized)Granit.Auditing, Granit.Http.ApiDocumentation
Terminal window
dotnet add package Granit.Auditing.EntityFrameworkCore
dotnet add package Granit.Auditing.Endpoints # optional — admin API
Program.cs
builder.AddGranitAuditingEntityFrameworkCore(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Auditing")));
services.AddDbContextFactory<AppDbContext>((sp, options) =>
{
options.UseNpgsql(connectionString);
options.UseGranitInterceptors(sp);
options.UseGranitAuditingInterceptor(sp); // AFTER UseGranitInterceptors
}, ServiceLifetime.Scoped);
app.MapAuditingEndpoints();

That’s it. Every SaveChanges on your AppDbContext now produces audit entries automatically.

The hierarchical model captures who did what, when, on which entities, down to individual property changes.

erDiagram
    AuditEntry ||--o{ AuditEntityChange : contains
    AuditEntityChange ||--o{ AuditPropertyChange : contains
    AuditEntry {
        guid Id PK
        datetimeoffset Timestamp
        string UserId
        string UserName
        enum Category
        string IpAddress
        string UserAgent
        guid TenantId
        string CorrelationId
    }
    AuditEntityChange {
        guid Id PK
        guid AuditEntryId FK
        string EntityType
        string EntityId
        enum ChangeType
    }
    AuditPropertyChange {
        guid Id PK
        guid AuditEntityChangeId FK
        string PropertyName
        text OriginalValue
        text NewValue
    }

Inspired by Google Cloud Audit Logs, entries are classified into categories with different retention defaults:

CategoryDescriptionDefault retentionAlways logged?
DataMutationEntity Create / Update / Delete / SoftDelete365 daysYes
ConfigurationChangeSettings, feature flags~7 yearsYes
DataAccessRead operations90 daysOpt-in
AccessDeniedAuthorization failures~7 yearsOpt-in

Entries are published to an in-memory Channel<T> and persisted by a background worker. Zero latency impact on SaveChanges, but entries may be lost on crash.

{ "Auditing": { "PersistenceMode": "Async" } }
[AuditIgnore]
public class CacheEntry : Entity { ... }
public class Patient : AuditedEntity
{
public string Name { get; set; }
[AuditIgnore]
public byte[] ProfilePhoto { get; set; } // Large binary, not audited
}
public class Patient : AuditedEntity
{
[AuditSensitive]
public string NationalId { get; set; } // Stored as "***" in audit trail
}
public class MyService(IAuditingReader auditingReader)
{
public async Task<PagedResult<AuditEntry>> GetPatientHistoryAsync(
string patientId, CancellationToken ct) =>
await auditingReader.GetByEntityAsync("Patient", patientId, cancellationToken: ct);
}
MethodRouteDescription
GET/audit-logPaginated list with filters (category, date, user, entity)
GET/audit-log/{id}Single entry with full entity + property change hierarchy
GET/audit-log/entity/{type}/{id}Audit trail for a specific entity (GDPR SAR)

All endpoints require the Auditing.AuditEntries.Read authorization policy (configurable).

The AuditingCleanupWorker runs periodically and deletes expired entries by category. Retention periods are configurable:

{
"Auditing": {
"ConfigurationChangeRetention": "2555.00:00:00",
"DataMutationRetention": "365.00:00:00",
"DataAccessRetention": "90.00:00:00",
"AccessDeniedRetention": "2555.00:00:00",
"CleanupInterval": "1.00:00:00",
"CleanupBatchSize": 10000
}
}

Deletes use ExecuteDeleteAsync in batches to avoid long-running transactions. Cascade deletes automatically remove child AuditEntityChange and AuditPropertyChange rows.

SignalNameDescription
TracingGranit.AuditingActivitySource for capture, persist, cleanup spans
Metricgranit.auditing.entries.persistedCounter — entries successfully persisted
Metricgranit.auditing.entries.purgedCounter — entries purged by cleanup (by category)
Metricgranit.auditing.capture.errorsCounter — errors during change tracking capture
KeyTypeDefaultDescription
PersistenceModeAsync / StrictAsyncHow entries are persisted
EnablePropertyTrackingbooltrueCapture property-level old/new values
PersistenceBatchSizeint50Entries per background worker batch (Async only)
ConfigurationChangeRetentionTimeSpan2555dRetention for config changes
DataMutationRetentionTimeSpan365dRetention for entity CRUD
DataAccessRetentionTimeSpan90dRetention for read operations
AccessDeniedRetentionTimeSpan2555dRetention for auth failures
CleanupIntervalTimeSpan24hInterval between cleanup runs
CleanupBatchSizeint10000Max entries deleted per batch
  • PersistenceAuditedEntityInterceptor and entity base classes
  • Privacy — GDPR data subject access requests
  • SecurityICurrentUserService and actor resolution