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
    • 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)
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
Granit.Auditing.NotificationsRoutes audit anomaly signals (repeated access-denied, role escalation, impersonation, privileged-config tampering) to admins / SOC via Email + InAppGranit.Auditing, Granit.Notifications, Granit.Templating
Granit.Auditing.PrivacyAuditingPrivacyDataProvider + PersonalDataRequestedEto handler — surfaces the user’s audit trail in the GDPR export ZIPGranit.Auditing, Granit.Privacy
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

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 pathSuccess audited?Failure audited?Owner & notes
Interactive login fronted by the BFFYesYesThe BFF owns it: it carries the real browser User-Agent and sets CreatedBy to the authenticated user.
OIDC token endpointauthorization_code / refresh_tokenNoYesA 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 authenticationNoYes (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.

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 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:

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

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.

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