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.
Package structure
Section titled “Package structure”DirectoryGranit.Auditing/ Abstractions, domain types, interfaces, options
DirectoryGranit.Auditing.EntityFrameworkCore/ Isolated DbContext, interceptor, persistence, cleanup
- …
DirectoryGranit.Auditing.Endpoints/ Read-only Minimal API endpoints
- …
| Package | Role | Depends on |
|---|---|---|
Granit.Auditing | Domain types, enums, IAuditingReader, IAuditingWriter, options | Granit, Granit.QueryEngine |
Granit.Auditing.EntityFrameworkCore | AuditingDbContext, interceptor, Channel persistence, cleanup worker | Granit.Auditing, Granit.Persistence |
Granit.Auditing.Endpoints | GET /audit-log endpoints (read-only, authorized) | Granit.Auditing, Granit.Http.ApiDocumentation |
Quick start
Section titled “Quick start”1. Install packages
Section titled “1. Install packages”dotnet add package Granit.Auditing.EntityFrameworkCoredotnet add package Granit.Auditing.Endpoints # optional — admin API2. Register services
Section titled “2. Register services”builder.AddGranitAuditingEntityFrameworkCore(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Auditing")));3. Add the interceptor to your DbContext
Section titled “3. Add the interceptor to your DbContext”services.AddDbContextFactory<AppDbContext>((sp, options) =>{ options.UseNpgsql(connectionString); options.UseGranitInterceptors(sp); options.UseGranitAuditingInterceptor(sp); // AFTER UseGranitInterceptors}, ServiceLifetime.Scoped);4. Map endpoints (optional)
Section titled “4. Map endpoints (optional)”app.MapAuditingEndpoints();That’s it. Every SaveChanges on your AppDbContext now produces audit entries automatically.
Domain model
Section titled “Domain model”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
}
Audit log categories
Section titled “Audit log categories”Inspired by Google Cloud Audit Logs, entries are classified into categories with different retention defaults:
| Category | Description | Default retention | Always logged? |
|---|---|---|---|
DataMutation | Entity Create / Update / Delete / SoftDelete | 365 days | Yes |
ConfigurationChange | Settings, feature flags | ~7 years | Yes |
DataAccess | Read operations | 90 days | Opt-in |
AccessDenied | Authorization failures | ~7 years | Opt-in |
Persistence modes
Section titled “Persistence modes”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" } }Entries are persisted synchronously within the interceptor’s SavedChangesAsync
callback. Guaranteed durability at the cost of added latency.
Required for ISO 27001 strict compliance (banking, HDS).
{ "Auditing": { "PersistenceMode": "Strict" } }Controlling what gets audited
Section titled “Controlling what gets audited”Skip an entity or property
Section titled “Skip an entity or property”[AuditIgnore]public class CacheEntry : Entity { ... }
public class Patient : AuditedEntity{ public string Name { get; set; }
[AuditIgnore] public byte[] ProfilePhoto { get; set; } // Large binary, not audited}Mask sensitive values
Section titled “Mask sensitive values”public class Patient : AuditedEntity{ [AuditSensitive] public string NationalId { get; set; } // Stored as "***" in audit trail}Querying the audit trail
Section titled “Querying the audit trail”Via IAuditingReader (code)
Section titled “Via IAuditingReader (code)”public class MyService(IAuditingReader auditingReader){ public async Task<PagedResult<AuditEntry>> GetPatientHistoryAsync( string patientId, CancellationToken ct) => await auditingReader.GetByEntityAsync("Patient", patientId, cancellationToken: ct);}Via admin endpoints
Section titled “Via admin endpoints”| Method | Route | Description |
|---|---|---|
GET | /audit-log | Paginated 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).
Retention and cleanup
Section titled “Retention and cleanup”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.
Observability
Section titled “Observability”| Signal | Name | Description |
|---|---|---|
| Tracing | Granit.Auditing | ActivitySource for capture, persist, cleanup spans |
| Metric | granit.auditing.entries.persisted | Counter — entries successfully persisted |
| Metric | granit.auditing.entries.purged | Counter — entries purged by cleanup (by category) |
| Metric | granit.auditing.capture.errors | Counter — errors during change tracking capture |
Configuration reference
Section titled “Configuration reference”| Key | Type | Default | Description |
|---|---|---|---|
PersistenceMode | Async / Strict | Async | How entries are persisted |
EnablePropertyTracking | bool | true | Capture property-level old/new values |
PersistenceBatchSize | int | 50 | Entries per background worker batch (Async only) |
ConfigurationChangeRetention | TimeSpan | 2555d | Retention for config changes |
DataMutationRetention | TimeSpan | 365d | Retention for entity CRUD |
DataAccessRetention | TimeSpan | 90d | Retention for read operations |
AccessDeniedRetention | TimeSpan | 2555d | Retention for auth failures |
CleanupInterval | TimeSpan | 24h | Interval between cleanup runs |
CleanupBatchSize | int | 10000 | Max entries deleted per batch |
See also
Section titled “See also”- Persistence —
AuditedEntityInterceptorand entity base classes - Privacy — GDPR data subject access requests
- Security —
ICurrentUserServiceand actor resolution