Audit & Compliance
Complete audit pipeline, entity tracking, SignalR streaming, CSV/Excel/PDF export, and compliance features.
SCRIPE provides a comprehensive audit system that tracks every data modification, API request, and security event. Audit logs are automatically generated by EF Core interceptors, streamed in real-time via SignalR, and exportable in CSV, Excel, and PDF formats.
Audit Architecture
The audit system consists of HTTP request logging and database entity mutation interception. Request metadata is logged asynchronously at the host level via the RequestLoggingMiddleware, while database-level mutations are captured by the AuditableEntityInterceptor before SaveChanges.
Hierarchical Tenant Scoping & Security Isolation
Data isolation is enforced dynamically at query execution. The DataScopeService resolves the administrator's effective scope based on a strict priority chain: ContextTenant (drill-down via AES-encrypted context headers), Permission Overrides, SystemProtectedAdmin, Hierarchy (including children), or OwnTenant. Descendants are traversed in constant time via materialized paths, where starts-with matches translate to index-backed SQL LIKE queries. The repository applies AuditByTenantScopeSpec to ensure 'WHERE TenantId IN (...)' isolation, while direct Guid-based requests are verified by GetAuditLogDetailQueryHandler to prevent horizontal privilege escalation.
AuditLog Entity Structure
/// <summary>
/// Represents a single audit log entry in the database.
/// Captures WHO did WHAT to WHICH entity, WHEN, and from WHERE.
/// </summary>
public class AuditLog : AuditableEntity
{
public string EventType { get; set; } = string.Empty;
public string? HttpMethod { get; set; }
public string? Endpoint { get; set; }
public string? EntityType { get; set; }
public string? EntityId { get; set; }
public string? OldValues { get; set; }
public string? NewValues { get; set; }
public string? ChangedProperties { get; set; }
public Guid? UserId { get; set; }
public Guid? TenantId { get; set; }
public string? Username { get; set; }
public bool IsAdmin { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? CorrelationId { get; set; }
public int? StatusCode { get; set; }
public long? DurationMs { get; set; }
public bool IsSuccess { get; set; } = true;
public string? ErrorMessage { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string? Metadata { get; set; }
public string? ModuleTag { get; set; }
}| Field | Type | Indexed | Description & Purpose |
|---|---|---|---|
| Id | Guid | Primary Key | Unique identifier for the audit record |
| EventType | string | ✅ Single / ✅ Composite | Event category (Create, Update, Delete, Login, etc.) |
| Timestamp | DateTime | ✅ Single / ✅ Composite | Execution timestamp in UTC. Sorts & filters logs |
| UserId | Guid? | ✅ Single | User ID of the actor executing the transaction |
| CorrelationId | string? | ✅ Single | Trace ID correlating HTTP requests to database transactions |
| TenantId | Guid? | ✅ Single / ✅ Composite | Tenant context enforcing boundary isolation |
| Endpoint | string? | ✅ Composite | Request API endpoint path, indexed with Timestamp |
| OldValues | string? (JSON) | — | JSON serialization of properties before mutation |
| NewValues | string? (JSON) | — | JSON serialization of properties after mutation |
Entity Change Interceptor
The AuditableEntityInterceptor hooks into EF Core's SaveChangesAsync pipeline. For every Added, Modified, or Deleted entity (including soft-deletes), it captures the old and new values as JSON, the user who made the change, and the timestamp. It skips AuditLog entities to prevent infinite recursion.
/// <summary>
/// EF Core interceptor that captures entity changes before SaveChanges.
/// Handles standard CRUD mutations, Soft Deletes, and prevents infinite recursion.
/// </summary>
public class AuditableEntityInterceptor : SaveChangesInterceptor
{
private readonly IAuditService _auditService;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct)
{
var context = eventData.Context;
if (context == null) return await base.SavingChangesAsync(eventData, result, ct);
// 1. Intercept Soft Delete BEFORE state is modified by BaseDbContext
var softDeleteEntries = context.ChangeTracker.Entries<ISoftDeletable>()
.Where(e => e.State == EntityState.Deleted)
.ToList();
foreach (var entry in softDeleteEntries)
{
await _auditService.LogEntityChangeAsync(
AuditEventTypes.Delete,
entry.Entity.GetType().Name,
GetEntityId(entry.Entity),
"Soft Deleted",
null,
"IsDeleted",
ct
);
}
// 2. Intercept Standard CRUD mutations
foreach (var entry in context.ChangeTracker.Entries())
{
if (entry.Entity is AuditLog) // Prevent infinite recursion StackOverflow
continue;
if (entry.State == EntityState.Unchanged || entry.State == EntityState.Detached)
continue;
string eventType = entry.State switch
{
EntityState.Added => AuditEventTypes.Create,
EntityState.Modified => AuditEventTypes.Update,
EntityState.Deleted => AuditEventTypes.Delete,
_ => "Unknown"
};
var oldValues = new Dictionary<string, object?>();
var newValues = new Dictionary<string, object?>();
var changedProps = new List<string>();
foreach (var prop in entry.Properties)
{
if (prop.Metadata.IsPrimaryKey()) continue;
if (entry.State == EntityState.Added)
{
newValues[prop.Metadata.Name] = prop.CurrentValue;
}
else if (entry.State == EntityState.Modified && prop.IsModified)
{
var original = entry.OriginalValues[prop.Metadata.Name];
var current = prop.CurrentValue;
if (!Equals(original, current))
{
oldValues[prop.Metadata.Name] = original;
newValues[prop.Metadata.Name] = current;
changedProps.Add(prop.Metadata.Name);
}
}
else if (entry.State == EntityState.Deleted)
{
oldValues[prop.Metadata.Name] = entry.OriginalValues[prop.Metadata.Name];
}
}
if (entry.State == EntityState.Modified && !changedProps.Any())
continue; // Skip if no properties changed
await _auditService.LogEntityChangeAsync(
eventType,
entry.Entity.GetType().Name,
GetEntityId(entry.Entity),
entry.State.ToString(),
Serialize(oldValues),
Serialize(newValues),
ct
);
}
return await base.SavingChangesAsync(eventData, result, ct);
}
}Real-Time SignalR Streaming
Audit logs are broadcast in real-time via the AuditHub SignalR hub. Connected admin clients receive instant notifications when any data changes occur, enabling live monitoring dashboards.
/// <summary>
/// SignalR hub for real-time audit log streaming.
/// Clients join tenant-specific groups for scoped updates.
/// </summary>
[Authorize]
public class AuditHub : Hub
{
public override async Task OnConnectedAsync()
{
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
if (tenantId != null)
{
await Groups.AddToGroupAsync(Context.ConnectionId, tenantId);
}
await base.OnConnectedAsync();
}
}
// In IAuditService — broadcast after persisting
public async Task LogAsync(AuditEntry entry)
{
await _dbContext.AuditLogs.AddAsync(entry.ToAuditLog());
await _dbContext.SaveChangesAsync();
// Broadcast to tenant group in real-time
await _hubContext.Clients
.Group(entry.TenantId.ToString())
.SendAsync("AuditLogCreated", entry.ToDto());
}Export Capabilities
The AuditExportService provides multi-format exports. CSV export uses CsvHelper with quotes around all fields to prevent CSV injection (RFC 4180) and a UTF-8 BOM prefix for Excel. Excel export generates a ClosedXML workbook with three detailed sheets: Executive Summary (dashboard stats/KPIs), Audit Data (with auto-filters, frozen headers, and green/red conditional formatting), and Security Analysis. PDF export uses QuestPDF layout engine, which is marked as Obsolete for large datasets due to memory footprint. To protect resources, all exports are capped to a maximum of 10,000 rows and loaded in-memory before transmission.
| Export Format | Service | Features |
|---|---|---|
| CSV | CsvExportService | Lightweight, spreadsheet-compatible, RFC 4180 quote enforcement, UTF-8 BOM for Excel |
| Excel (.xlsx) | ExcelExportService | ClosedXML workbook with Executive Summary (KPI metrics dashboard), Audit Data (auto-filters, frozen headers, green/red conditional formatting), and Security Analysis sheets |
| PdfExportService | QuestPDF community license, Cover page, executive summary charts, Obsolete for large datasets due to RAM overhead |
public interface IExportService
{
Task<byte[]> ExportAsync<T>(
IEnumerable<T> data,
ExportOptions options,
CancellationToken ct = default);
}
public class ExportOptions
{
public string Title { get; set; } = "Export";
public string[] Columns { get; set; } = Array.Empty<string>();
public string DateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss";
public bool IncludeHeaders { get; set; } = true;
public string? WatermarkText { get; set; } // PDF only
public string? BrandingLogoPath { get; set; } // PDF only
}Query & Export API
The audit API provides search, filter, and export capabilities for audit logs. All endpoints require admin authentication and the audit.view or audit.export permission.
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/audit | Search and filter audit logs with pagination | 🔒 Required |
| GET | /api/v1/audit/export?format=csv | Export audit logs as CSV file | 🔒 Required |
| GET | /api/v1/audit/export?format=excel | Export audit logs as Excel spreadsheet | 🔒 Required |
| GET | /api/v1/audit/export?format=pdf | Export audit logs as PDF document | 🔒 Required |
GET /api/v1/audit?
userId=a1b2c3d4...
&action=Modified
&entityName=Admin
&from=2026-01-01T00:00:00Z
&to=2026-02-20T23:59:59Z
&search=password
&page=1
&pageSize=25
&sortBy=timestamp
&sortDirection=descCompliance Features
Full Trace
Captures HTTP headers, request context, and entity mutations to ensure complete trace visibility.
Immutable Logs
Logs are locked and stored in a read-only fashion, preventing deletion or updates once committed.
Tenant Scoped
Logs are automatically isolated by TenantId and descendant tree boundaries, preventing leaks.
Retention Policy
Configured retention periods automatically purge expired audit logs via background workers.
Searchable
Optimized indexes on Timestamp, UserId, EventType, and CorrelationId enable instant lookup.
Real-Time
Stream live security events and mutations directly to tenant-scoped SignalR dashboards.