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.ConfigurationChanges/ Setting & feature flag change audit handlers
- …
DirectoryGranit.Auditing.EntityFrameworkCore/ Isolated DbContext, interceptor, persistence, cleanup
- …
DirectoryGranit.Auditing.Endpoints/ Read-only Minimal API endpoints
- …
DirectoryGranit.Auditing.Notifications/ SOC/admin notification bridge — pages humans on audit anomalies (ISO 27001 A.12.4)
- …
DirectoryGranit.Auditing.Privacy/ Wolverine handler exporting the user’s audit trail on a GDPR Art. 15 request (drop-in
IPrivacyDataProvider)- …
| Package | Role | Depends on |
|---|---|---|
Granit.Auditing | Domain types, enums, IAuditingReader, IAuditingWriter, options | Granit, Granit.QueryEngine |
Granit.Auditing.ConfigurationChanges | Audit handlers for SettingChangedEvent and FeatureOverrideChangedEvent | Granit.Auditing, Granit.Settings, Granit.Features |
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 |
Granit.Auditing.Notifications | Routes audit anomaly signals (repeated access-denied, role escalation, impersonation, privileged-config tampering) to admins / SOC via Email + InApp | Granit.Auditing, Granit.Notifications, Granit.Templating |
Granit.Auditing.Privacy | AuditingPrivacyDataProvider + PersonalDataRequestedEto handler — surfaces the user’s audit trail in the GDPR export ZIP | Granit.Auditing, Granit.Privacy |
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. Map endpoints (optional)
Section titled “3. Map endpoints (optional)”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.
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 |
Authentication audit — owned at its source
Section titled “Authentication audit — owned at its source”Authentication crosses several components — a BFF session, the OIDC token endpoint, an API
key handler — and each could plausibly write its own login audit. If they all did, a single
sign-in would produce duplicate rows, half of them with a machine’s User-Agent instead of
the browser’s. Granit applies one rule: the component that performs the authentication owns
its audit, and no one else re-audits it.
| Authentication path | Success audited? | Failure audited? | Owner & notes |
|---|---|---|---|
| Interactive login fronted by the BFF | Yes | Yes | The BFF owns it: it carries the real browser User-Agent and sets CreatedBy to the authenticated user. |
OIDC token endpoint — authorization_code / refresh_token | No | Yes | A grant exchange is not a fresh authentication — it redeems one already audited at its source (the BFF callback, or the interactive login for direct clients). Re-auditing would duplicate that row. Failures are still audited: a forged or replayed code/token is a security event seen only here. |
| API key authentication | No | Yes (AccessDenied) | A key authenticates on every call; auditing each one is noise. What the key does is attributed via CreatedBy on the entities it touches. Only rejections (invalid, revoked, expired, IP not allow-listed) are audited. |
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.
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" } }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}Protect sensitive values
Section titled “Protect sensitive values”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; }}| Mode | Audit trail value | Use case |
|---|---|---|
Mask (default) | *** | PII that must be recorded but not revealed |
Omit | null | Secrets that must never appear in audit trail |
Hash | sha256:a1b2c3... | Values needing cross-reference without exposure |
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 |
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 |
ChannelCapacity | int | 10000 | Max buffered batches in Async mode (backpressure) |
CacheEntryTtl | TimeSpan | 30m | Cache duration for individual audit entries |
CacheEntityQueryTtl | TimeSpan | 2m | Cache duration for entity-scoped queries |
Notifications
Section titled “Notifications”Granit.Auditing.Notifications surfaces high-signal events to platform admins / SOC
operators in near real time rather than relying on a silent log line. The point
isn’t to drown the inbox — it’s to satisfy ISO 27001 A.12.4 logging-and-monitoring
by paging a human responder on the patterns that matter.
Anomaly heuristics detect: repeated access-denied bursts, role escalation,
impersonation activity, and privileged configuration tampering. Each match emits a
auditing.anomaly_detected notification:
| Notification | Trigger | Channels | Severity |
|---|---|---|---|
auditing.anomaly_detected | AuditEntryPersistedEto (filtered by anomaly heuristics) | Email, InApp | Warning |
Email templates ship embedded in English and French; additional cultures are produced via the translation script and the runtime admin override path.
GDPR Article 15 — audit trail in the user’s export
Section titled “GDPR Article 15 — audit trail in the user’s export”Granit.Auditing.Privacy plugs into the Granit.Privacy scatter-gather pipeline:
register the package, and AuditingPrivacyDataProvider automatically responds to
PersonalDataRequestedEto with the requester’s audit history (filtered by
UserId). The fragment is uploaded through PrivacyFragmentUploader and bundled
into the export ZIP — no extra wiring per host.
See also
Section titled “See also”- Persistence —
AuditedEntityInterceptorand entity base classes - Privacy — GDPR data subject access requests
- Security —
ICurrentUserServiceand actor resolution