Skip to content

Architecture

RzR edited this page Apr 20, 2026 · 1 revision

Architecture

This page explains how the DataVigil packages relate to each other and how data flows from an EF Core SaveChanges call all the way to the audit store.


Package Dependency Graph

RzR.DataVigil.Abstractions          (interfaces, enums, models)
        │
        ▼
RzR.DataVigil.Core                  (pipeline, GDPR, options, resolvers)
        │
   ┌────┼──────────────┐
   │    │              │
   ▼    ▼              ▼
EFCore  AspNetCore    Storage.File
   │
   ├── Storage.EfSqlServer
   ├── Storage.EfPostgreSql
   └── Storage.EfMongoDb

Everything depends on Core, which pulls in Abstractions transitively. The EF-based storage packages reference EFCore for the shared AuditDbContextBase. The file storage provider talks directly to Core since it doesn't use Entity Framework at all.

AspNetCore is completely optional. It exists to resolve user identity and correlation IDs from HttpContext. Without it, the library falls back to Thread.CurrentPrincipal and System.Diagnostics.Activity.


CUD Audit Flow (SaveChanges)

Here's what happens when your application calls SaveChangesAsync() on an intercepted DbContext:

  DbContext.SaveChangesAsync()
        │
        ▼
  AuditSaveChangesInterceptor.SavingChangesAsync()
        │
        ├── Filters ChangeTracker entries:
        │     - Must implement IAuditable
        │     - Entity state must be Added, Modified, or Deleted
        │     - Not in global exclusions
        │     - Not in context-level exclusions (IAuditableContext)
        │     - Passes IAuditableEntity.ShouldAudit() if applicable
        │
        ├── ChangeTrackerEntryBuilder.Build() for each entry
        │     - Create: all current values (OldValue = null)
        │     - Update: only changed properties (old != new)
        │     - Delete: all original values (NewValue = null)
        │     - Extracts primary key (single or composite)
        │     - Removes excluded fields (IAuditableEntity.GetExcludedFields())
        │
        ▼
  AuditPipeline.ProcessAsync(AuditTransaction)
        │
        ├── 1. Enriches transaction:
        │       UserId, UserName, IpAddress   <- IAuditUserResolver
        │       Source                        <- IAuditSourceResolver
        │       CorrelationId, TraceId        <- IAuditCorrelationProvider
        │
        ├── 2. GDPR storage policies:
        │       GdprProcessor.ApplyStoragePolicies(entry)
        │         → Exclude: removes property entirely
        │         → Mask:    "alice@mail.com" → "a***m"
        │         → Anonymize: "[ANONYMIZED]"
        │         → Hash:   SHA-256 hex
        │         → Custom: your Func<string,string>
        │       Sets GdprStorageState on the transaction
        │
        ├── 3. Persists:
        │       IAuditStore.SaveAsync(transaction)
        │
        ▼
  Original SaveChanges proceeds normally

The interceptor fires before the actual database save. If the audit pipeline throws, the main save still goes through - audit failures are handled gracefully.


Read Audit Flow (SELECT)

Read auditing is opt-in. There are two mechanisms, one for relational databases and one for MongoDB.

Relational (SQL Server, PostgreSQL)

The AuditCommandInterceptor hooks into ReaderExecutedAsync. After a SELECT completes:

  1. Parses table names from the SQL text (FROM / JOIN clauses)
  2. Resolves each table to an IEntityType via EF Core metadata
  3. Skips anything that doesn't implement IAuditable
  4. Extracts entity IDs from WHERE clause parameters
  5. Optionally captures column names and values
  6. Adds AuditEntry objects to the scoped AuditReadCollector

MongoDB

MongoDB doesn't go through DbCommand, so it uses an AuditMaterializationInterceptor (implements IMaterializationInterceptor). This fires every time EF Core materializes an entity instance. If the entity is IAuditable, a read entry gets collected.

Flushing

In ASP.NET Core, the UseAuditReadFlush() middleware runs after the response completes. It grabs the AuditReadCollector from DI, wraps all collected entries into a single AuditTransaction, and sends it through AuditPipeline.ProcessAsync().

In non-web scenarios, you'd flush manually or use AuditReadService.LogReadAsync().


GDPR Processing Layers

GDPR has two independent layers that operate at different points in the lifecycle:

  ┌──────────────────────────────────┐
  │         STORAGE POLICIES         │
  │  (applied BEFORE persistence)    │
  │                                  │
  │  Raw value -> Transformed value  │
  │  Original data never stored      │
  └──────────────────────────────────┘
                  │
                  ▼
            IAuditStore
           (data at rest)
                  │
                  ▼
  ┌──────────────────────────────────┐
  │        RETRIEVAL POLICIES        │
  │   (applied WHEN querying)        │
  │                                  │
  │  Stored value -> Visible value   │
  │  Role/claim gated per field      │
  └──────────────────────────────────┘

Storage policies are irreversible. The raw data is gone after transformation. Retrieval policies are dynamic - the same stored value shows up differently depending on who's asking.


DI Registration Order

The order matters because each registration builds on what came before:

1. AddAuditTrail(options => { ... })     <- creates AuditTrailOptions, registers core services
2. AddAuditTrailEfCore()                 <- registers interceptors (needs options from step 1)
3. AddAuditTrailSqlServer()              <- registers IAuditStore (needs options from step 1)
   AddAuditTrailPostgreSqlServer()
   AddAuditTrailMongoDb()
   AddAuditTrailFileStorage()
4. AddAuditTrailAspNetCore()             <- replaces default resolvers with HttpContext-aware ones
5. AddAuditRetentionService()            ← optional background purge

Internally, AddAuditTrail calls AuditTrailBuilder.Build() which registers:

  • AuditTrailOptions as singleton
  • GdprPolicyRegistry and GdprProcessor as singletons
  • AuditPipeline as scoped (one per request/scope)
  • IAuditScopeContext as scoped
  • Default resolvers (replaced if you call UseUserResolver<T>() or UseSourceResolver<T>(), or by AspNetCore)

Threading and Scope Model

  • AuditPipeline is scoped - each HTTP request or DI scope gets its own instance
  • AuditReadCollector is scoped - read entries accumulate per-request, then flush
  • IAuditScopeContext is scoped - allows setting user identity for non-web scenarios
  • GdprProcessor and GdprPolicyRegistry are singletons - GDPR rules don't change at runtime
  • FileAuditStore uses lock for thread-safe file writes
  • AuditReadCollector.Collect() is thread-safe (concurrent read interceptors can fire in parallel)

Clone this wiki locally