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.ConfigurationChanges/ Setting & feature flag change audit handlers
    • 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.ConfigurationChangesAudit handlers for SettingChangedEvent and FeatureOverrideChangedEventGranit.Auditing, Granit.Settings, Granit.Features
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")));
app.MapGranitAuditing();

That’s it. AddGranitAuditingEntityFrameworkCore registers the AuditingChangeTrackingInterceptor as an IGranitAutoInterceptor. All DbContexts created via AddGranitDbContext<T> (or multi-tenant factories) pick it up automatically through UseGranitInterceptors — no additional wiring needed.

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. Transient database failures are retried automatically (3 attempts with exponential backoff). If all attempts fail, the entry is dropped and a Critical-level log is emitted for alerting.

{ "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
}

Use the cross-cutting [SensitiveData] attribute from Granit.DataProtection. It is consumed by the auditing module, AI/MCP output sanitization, and logging.

public class Patient : AuditedEntity
{
[SensitiveData] // Default (Mask) → stored as "***"
public string NationalId { get; set; }
[SensitiveData(Mode = SensitiveDataMode.Omit)] // Removed entirely from audit trail
public string? PasswordHash { get; set; }
[SensitiveData(Mode = SensitiveDataMode.Hash)] // SHA-256 hash for correlation
public string? ExternalUserId { get; set; }
}
ModeAudit trail valueUse case
Mask (default)***PII that must be recorded but not revealed
OmitnullSecrets that must never appear in audit trail
Hashsha256:a1b2c3...Values needing cross-reference without exposure
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
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
ChannelCapacityint10000Max buffered batches in Async mode (backpressure)
CacheEntryTtlTimeSpan30mCache duration for individual audit entries
CacheEntityQueryTtlTimeSpan2mCache duration for entity-scoped queries

Granit.Auditing.Notifications ships notifications surfacing high-signal events that an SOC or compliance officer should know about — typically anomalies in the audit stream itself (e.g. spikes in access-denied events, configuration changes outside business hours).

NotificationTriggerChannelsSeverity
auditing.anomaly_detectedAuditEntryPersistedEto (filtered by anomaly heuristics)Email, InAppWarning

Email templates ship in EN + FR; additional cultures are produced via the translation script (US #1311).

  • PersistenceAuditedEntityInterceptor and entity base classes
  • Privacy — GDPR data subject access requests
  • SecurityICurrentUserService and actor resolution