Ana içeriğe geç

OrionAudit

OrionAudit Logo

OrionAudit

EF Core change-audit trail with JSON Patch diffs, multi-tenant support, and time-travel reconstruction

NuGet Downloads License Target


Current release: v2.0.0 — an API cleanup, enforced by an analyzer from here on. Writing the public API down for v1.0.0 is what made its problems visible, hours after it shipped: the library's own instrumentation helpers and chain stamper were public, dispatcher bookkeeping was hand-mutable, an ASP.NET Core options type sat in the framework-agnostic package, and two signatures broke the rules that keep overloads addable. v2.0.0 fixes all of it and promotes the corrected surface to the shipped baseline, so the next accidental change fails the build instead of shipping. It has breaking changes — read the 2.0.0 changelog entry before upgrading.

v1.0.0, released the same day, is the correctness release underneath it: synchronous SaveChanges() is captured, capture and redaction work under UseLazyLoadingProxies(), pooled/factory contexts no longer misattribute rows, the viewer demands an explicit access decision and HTML-encodes what it renders, the tenant filter scopes to the no-tenant stream instead of failing open when the tenant cannot be resolved, and the hash chain's anchor lock is actually held. It carries a schema migration — if you are coming from 0.11.3, read its changelog entry too. Recent milestones: v0.11.0 richer history filters + aggregations, v0.10.0 background compaction + history export, and v0.9.0 tamper-evident hash-chaining — opt in with o.UseHashChain(h => h.UseKey(...)) and every captured AuditLog row gains a keyed HMAC-SHA256 EntryHash that chains it to the row before it (per entity stream, per tenant), so a later edit, deletion (including tail/whole-stream truncation), or reordering of any row is detectable and unforgeable without the MAC key, which lives outside the audit database. IAuditIntegrityVerifier.VerifyChainAsync walks the chain and reports the first broken row plus the reason. It is off by default and fully additive. Earlier: v0.8.0 queryable history + compaction, v0.7.0 publisher hook, v0.6.0 developer experience, v0.5.0 async staging-capture + viewer, v0.4.0 AOT-clean diff, v0.3.0 source-gen, v0.2.0 scale, v0.1.0 capture. See the changelog and what's next.


How it works​

A SaveChangesInterceptor sits in EF Core's pipeline. For every [Auditable] entity in Added, Modified, or Deleted state it builds a snapshot, runs the diff engine against the previous snapshot (loaded from AuditLog history or a periodic snapshot), and writes one AuditLog row in the same transaction as the data change. Synchronous mode writes the final row directly; async mode writes a lightweight queue row instead and lets a dispatcher hosted service materialize the diff off the hot path.

The diagram makes the two key guarantees visible. In sync mode the AuditLog row and the domain rows commit together: either both exist or neither does. In async mode the same atomicity holds for the Capture_Queue row, and the dispatcher's "claim, materialize, insert final" trio is itself one transaction so deferred rows are exactly-once.


Why OrionAudit?​

FeatureOrionAuditAudit.NETEFCore.TriggeredDIY pattern
EF Core SaveChanges interceptionYesYesYesYes
JSON Patch (RFC 6902) diffsYesYes--
Time-travel reconstructionYes---
Sensitive-field attributes (Hash/Redact)Yes---
Multi-tenant read-side filterYes---
Pluggable user / tenant resolversYesYes-Yes
ASP.NET Core HttpContext resolverYesYes--
OpenTelemetry ActivitySource + MeterYes---
Framework-agnostic test helpersYes---
Multi-targets net8 / net9 / net10YesYesYesn/a
Source-generated type discoveryYes---
NativeAOT cleanYes---
Composite primary key supportYesYesYesYes
Periodic snapshotting (O(K) replay)Yes---
Retention policy + background sweepYes---
Soft-delete capture (distinct action)Yes---
Provider column hints (jsonb / nvarchar(max))Yes---
Opt-in async staging-capture (atomic, lossless)Yes---
Embedded audit-trail UI (no Blazor, no build step)Yes---
Storage-agnostic queryable history read APIYes---
Snapshot compaction (bounded retained tail)Yes---

Quick Start (60 seconds)​

dotnet add package OrionAudit
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionAudit;

// Register configuration and (optional) resolvers
services.AddOrionAudit<AppDbContext>(o => o
.Audit<Order>()
.Audit<Customer>(b => b
.Hash(c => c.Email) // store SHA-256 hex, not the plaintext
.Redact(c => c.ApiKey))); // store the literal "<redacted>"

// Wire the interceptor into your DbContext
services.AddDbContext<AppDbContext>((sp, o) =>
o.UseSqlServer(connectionString)
.UseOrionAudit(sp));
// AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyOrionAuditConfigurations();
}

That's it — every SaveChanges on an [Auditable] entity now writes an AuditLog row in the same transaction.


Ecosystem Packages​

PackageInstallPurpose
OrionAuditdotnet add package OrionAuditCore library — interceptor, diff, reconstruction
OrionAudit.AspNetCoredotnet add package OrionAudit.AspNetCoreHttpContextAuditUserResolver + DI helpers
OrionAudit.MySqldotnet add package OrionAudit.MySqlMySQL / MariaDB provider (ApplyOrionAuditMySqlConfigurations, JSON/LONGTEXT columns)
OrionAudit.Viewerdotnet add package OrionAudit.ViewerEmbedded read-only audit-trail UI (MapOrionAuditViewer)
OrionAudit.Testingdotnet add package OrionAudit.TestingAuditCapture + fluent assertions, framework-free

What's new in v0.9.0​

Tamper-evident hash-chaining​

Opt in with o.UseHashChain(...). Each captured AuditLog row then gets a keyed HMAC-SHA256 EntryHash that binds its content (including any registered custom columns) to the row before it in the same chain scope (per entity stream, per tenant), plus a PreviousHash column and a HashKeyId. A later edit, deletion (including deleting the tail or an entire stream), reordering, or out-of-band insertion of any row is detected by the verifier. The one deletion that is not reported is one the retention sweep performed and recorded on the anchor — see Retention and the chain below.

The chain is a keyed MAC, not a bare hash: the key comes from an IAuditChainKeyProvider that lives outside the audit database. That is what makes the chain unforgeable - with a plain SHA-256 chain, anyone who can write rows could recompute the hashes and fake a valid chain. So a key is required; enabling without one fails fast. Store the secret outside the audit database (a secret manager / KMS / environment secret).

services.AddOrionAudit<AppDbContext>(o =>
{
o.Audit<Order>();
// off by default; supply a key (base64, >= 16 bytes) loaded from a secret store.
o.UseHashChain(h => h.UseKey(keyId: 1, base64Key: Environment.GetEnvironmentVariable("AUDIT_CHAIN_KEY")!));
});

A persisted per-stream anchor (OrionAudit_Chain_Anchor) makes concurrent same-stream writes safe (they serialize on the anchor row inside the write transaction — yours if you opened one, otherwise one OrionAudit opens around the stamp and commits together with the audit rows) and makes tail/whole-stream deletion detectable (the anchor remembers the true tail hash and row count). The key id is stored per row, so you can rotate keys later without invalidating rows written under an older (still-registered) key.

Two things worth knowing about those locks before you reason about your own:

  • They are pessimistic row locks taken inside the write transaction (SELECT ... FOR UPDATE / WITH (UPDLOCK, HOLDLOCK)), one per stream the save touches, and a save spanning several streams holds each while it goes after the next. They are therefore taken in one global order across all callers, so two batches touching the same streams in opposite orders cannot deadlock each other. Budget lock-wait time accordingly on a hot entity.
  • On SQLite a contending same-stream save waits rather than fails. There is no row-lock statement; serialization comes from BEGIN IMMEDIATE, so the second writer blocks at its own BEGIN on the connection's busy timeout (30 seconds by default) and then reads the head the first one committed. One SaveChangesAsync per writer is enough — no retry loop. The exception is a shared-cache in-memory database (mode=memory&cache=shared), which serializes with table locks reporting SQLITE_LOCKED; SQLite's busy handler does not wait on those, so concurrent chained writers there fail instead of queueing. That is a test-fixture shape, not a deployment one — use a file database if your tests write one stream concurrently.

If your DbContext uses a retrying execution strategy (EnableRetryOnFailure()), you have to own that transaction yourself — EF Core only lets the code that owns the SaveChanges call open one inside a retriable unit, and an interceptor is not that code. Wrap your saves once and the chain stamps inside your transaction:

var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync();
await db.SaveChangesAsync();
await transaction.CommitAsync();
});

Without it the first hash-chained save throws OrionAuditConfigurationException carrying exactly that snippet. The async-capture dispatcher needs nothing from you — it owns its own save and already runs the whole unit through your strategy.

UseHashChain() adds four nullable columns (EntryHash, PreviousHash, HashKeyId, ChainSequence) to the audit table plus the OrionAudit_Chain_Anchor table, so add a migration after enabling it:

dotnet ef migrations add AddOrionAuditHashChain

Upgrading to v1.0.0 with chaining already enabled needs a migration too, not just a fresh opt-in: ChainSequence is new on the audit table, and OrionAudit_Chain_Anchor gains PrunedRowCount (non-nullable, defaults to 0) and PrunedThroughHash (nullable). One migration covers all three, and no backfill is needed — the defaults mean "not known" / "never pruned", which is exactly how the walk and the verification treat pre-upgrade rows. The migration is backward compatible, so a v0.11.3 process runs against the migrated schema unchanged; apply it before the cutover below rather than during it.

Drain before you cut over — do not roll v0.11.3 and v1.0.0 writers side by side. v0.11.3's anchor lock was never actually held (it was released before the anchor head was read — one of the defects v1.0.0 fixes). A v1.0.0 writer holds that lock correctly but cannot serialize against an old writer that is not honouring it, so during an overlap an old and a new process writing the same stream can both read head H and both stamp PreviousHash = H. That is a fork, not a mis-ordering: ChainSequence orders a chain, it cannot repair one that branched, and VerifyChainAsync will report BrokenLink on a trail nobody tampered with, permanently. Let the v0.11.3 instances finish their in-flight saves and stop taking audited work, then start the v1.0.0 ones. A blue/green cutover works if the old side is drained first; an instance-by-instance rolling restart does not, because it is defined by both versions serving at once.

This applies only if you enable UseHashChain. Without chaining there is no anchor, no lock and nothing stamped — roll normally. It also does not apply if your streams are already partitioned so one stream is only ever written by one process; the hazard is two versions writing one stream, not the two versions coexisting.

Retention and the chain​

Retention and hash-chaining work together; until v1.0.0 they did not. The sweep deletes the oldest rows of a stream, which the chain could not tell apart from an attacker deleting them, so from the first purge onward every VerifyChainAsync on a pruned stream returned BrokenLink or Truncated — permanently. Now the sweep records what it removed on the anchor (PrunedRowCount, PrunedThroughHash) in the same transaction as the delete, and the verifier checks walked + PrunedRowCount == RowCount and expects the surviving genesis to link to PrunedThroughHash. A deletion no sweep recorded still fails, as does a mutated row.

Two operational consequences when chaining is on:

  • A sweep may delete fewer rows than the policy selected. Retention is only ever allowed to prune a chain's head, because re-anchoring at the oldest survivor repairs a pruned head but cannot close a hole in the middle. Each batch is narrowed to the longest contiguous run starting at the stream's current head; anything after the first gap stays for a later sweep and goes as soon as the row that blocked it ages out too. Under-deletion on one cycle is expected, not a bug.
  • The ExecuteDelete fast path is off. The chain repair has to know which streams lost which rows, which a bare ExecuteDelete never reveals, so the sweep materialises each batch instead. The batch is already bounded by MaxRowsPerSweep. Consumers without hash-chaining keep the fast path unchanged, and dry-run still deletes nothing and writes no checkpoint.

Verify the chain through the DI-registered IAuditIntegrityVerifier:

using Moongazing.OrionAudit.Integrity;

var verifier = serviceProvider.GetRequiredService<IAuditIntegrityVerifier>();

// One entity's trail...
var result = await verifier.VerifyChainAsync(
AuditChainVerificationRequest.ForEntity(typeof(Order).AssemblyQualifiedName!, order.Id.ToString()));

// ...or the whole table.
var all = await verifier.VerifyChainAsync(AuditChainVerificationRequest.All());

if (!all.IsValid)
{
// all.BrokenAtId / all.BrokenEntityType / all.BrokenEntityId / all.Reason pinpoint the first break.
Console.WriteLine($"Audit chain broken at {all.BrokenAtId}: {all.Reason} ({all.Detail})");
}

The chain is opt-in and backward compatible: rows written before you enabled it keep a null hash and verify as an unchained prefix the verifier skips, so verification begins at each stream's first hashed (genesis) row. Capture, diffs, snapshot compaction, and the read APIs are unchanged whether or not chaining is on. Canonicalization is deterministic and stable across a database round-trip (fixed field order, length-prefixed fields, invariant culture, UTF-8, and a precision-stable timestamp), so a legitimately persisted row always re-verifies.

What's new in v0.8.0​

IAuditHistoryStore — storage-agnostic queryable history read API​

IAuditHistoryStore is a read and maintenance surface over recorded AuditLog rows that does not bind to where those rows live. The default EfCoreAuditHistoryStore is registered by AddOrionAudit against your DbContext, so it resolves from DI with no extra wiring:

using Moongazing.OrionAudit.Store;

var store = serviceProvider.GetRequiredService<IAuditHistoryStore>();

// Every write to a single Order by one user, oldest first, second page of 50.
var page = await store.QueryAsync(new AuditHistoryQuery
{
EntityType = typeof(Order).AssemblyQualifiedName,
EntityId = order.Id.ToString(),
UserId = "u-123",
Action = AuditAction.Updated,
FromUtc = DateTime.UtcNow.AddDays(-30),
ToUtc = DateTime.UtcNow,
Order = AuditHistoryOrder.OldestFirst,
Skip = 50,
Take = 50,
});

foreach (var row in page.Items)
{
Console.WriteLine($"{row.OccurredOnUtc:o} {row.Action} {row.UserId}");
}

// page.TotalCount is the match count before paging; page.HasMore drives "load more".
Console.WriteLine($"showing {page.Items.Count} of {page.TotalCount}, more={page.HasMore}");

Every filter on AuditHistoryQuery is optional. A default-constructed query returns the whole history, newest first, capped by AuditHistoryQuery.DefaultPageSize (100), so an unfiltered query never materialises an unbounded result. FromUtc and ToUtc are inclusive bounds and must be UTC instants (DateTimeKind.Utc). Validate() rejects a negative Skip, a Take below 1, a non-UTC bound, or an inverted time range, and each store calls it before executing so every backend reports the same diagnostics.

AuditHistoryStoreBase supplies a capability default that throws NotSupportedException for each operation, so a backend that cannot page or cannot compact overrides only what it can honour. This mirrors the family's DeleteAuditArchiver-as-default pattern. OrionAudit.Testing ships an InMemoryAuditHistoryStore that implements the full surface over an in-memory row list for tests and prototyping against the abstraction.

Snapshot compaction — fold old history into a base snapshot​

Compaction collapses a long Insert-then-many-Updates history for one entity into a single compacted snapshot row, plus a bounded retained tail of the most-recent rows kept verbatim. The folded rows are removed, which bounds storage growth while keeping the latest state fully reconstructable.

var result = await store.CompactAsync(new AuditCompactionRequest
{
EntityType = typeof(Order).AssemblyQualifiedName!,
EntityId = order.Id.ToString(),
RetainTail = 20, // keep the 20 most-recent rows verbatim after the snapshot
TenantId = "tenant-acme", // optional: scope to one tenant's rows for a shared id
});

Console.WriteLine($"folded {result.RowsRemoved} rows: {result.RowsBefore} -> {result.RowsAfter}");

RetainTail is the number of most-recent rows kept after the snapshot; zero collapses the entire history into one snapshot row. When the history is too short to gain anything the call is a no-op (SnapshotWritten is false). A folded Deleted or SoftDeleted boundary stays a terminal state. The EfCoreAuditHistoryStore applies the plan as one insert plus delete inside a single SaveChanges transaction, so a failure leaves the history untouched. The folding engine replays the history over AuditLog JSON via the in-house DiffEngine, so it carries no reflection and stays trim-safe and Native-AOT clean.


What's new in v0.6.0​

AddColumn — tipped, indexable custom columns​

services.AddOrionAudit<AppDbContext>(o => o
.Audit<Order>()
.AddColumn<int>("WorkflowStepId", ctx => (ctx.Entity as IHasWorkflow)?.StepId)
.AddColumn<string>("Source", ctx => ctx.Action == AuditAction.Inserted ? "import" : "app"));

// OnModelCreating: pick up registered columns automatically.
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyOrionAuditConfigurations(this);

// LINQ filter on a real, indexable column:
var fromStep3 = await db.AuditLog()
.Where(a => EF.Property<int?>(a, "WorkflowStepId") == 3)
.ToListAsync();

Add a CreateIndex in your EF migration for any column you'll filter on. The provider runs inside the capture transaction with the audited entity in scope; failure annotates AuditLog.Error and leaves the column NULL. In async-capture mode the value rides through the queue's new CustomColumnsJson column and lands on the final AuditLog after dispatch.

AuditImportBuilder — bulk historical import, idempotent​

var import = db.CreateAuditImport(o =>
{
o.BatchSize = 1000;
o.ImportBatch = "legacy-orders-2026"; // REQUIRED — drives idempotency
});

import.Add<Order>(e => e
.Key(legacy.OrderId)
.Action(AuditAction.Updated)
.Before(oldState).After(newState)
.By("u-123", "Legacy User")
.At(legacy.ChangedAtUtc)
.SourceId(legacy.RowId));

var result = await import.SaveAsync();
// result.Written / Skipped / DeadLettered

ImportBatch is mandatory — it stamps AuditLog.CorrelationId as import:{ImportBatch}#{SourceId}, which is what makes a re-run safe: a record whose row is already present reports as Skipped. Idempotency is per record, so it needs SourceId(...). A record added without one is stamped with the batch-wide import:{ImportBatch} instead, which identifies the batch and not the record, so it is never reported as Skipped and re-adding it writes a second row. Retrying SaveAsync on the same builder after a failed flush is safe either way: only the records that actually reached the database leave the buffer, so the retry writes exactly the ones that did not. Imported diffs are byte-for-byte equal to the diffs the live capture path produces (a parity test enforces this). Import always writes AuditLog directly, bypassing the async-capture queue.


What's new in v0.5.0​

Async staging-capture — atomic, lossless, off the hot path​

Synchronous capture (the default since v0.1.0) writes the AuditLog row in the same transaction as the originating change. Under high write load the diff computation and the extra row become measurable overhead. Opt-in UseAsyncCapture() keeps the atomicity guarantee but defers the heavy work:

services.AddOrionAudit<AppDbContext>(o => o
.Audit<Order>()
.UseAsyncCapture(q => q
.PollInterval(TimeSpan.FromSeconds(2))
.BatchSize(500)
.MaxAttempts(5)));
  • The interceptor writes a lightweight OrionAudit_Capture_Queue row in the same transaction as the data change — capture stays atomic and lossless.
  • AuditDispatcherHostedService polls the queue, computes diffs, and writes the final AuditLog rows. Inserts and deletes commit together → exactly-once.
  • A row that throws is retried up to MaxAttempts and then dead-lettered (Error column set; surfaced via orionaudit.dispatch.rows_deadlettered telemetry).
  • IAuditDispatcher.FlushPendingAsync(ct) force-drains the queue for tests and read-after-write call sites. A no-op implementation is registered in synchronous mode so the dependency is always resolvable.

Trade-off to know: in async mode AuditFor<T>() sees only dispatched rows, so audit is eventually consistent. Use FlushPendingAsync where you need read-after-write.

OrionAudit.Viewer — read-only audit UI, one line to embed​

app.MapOrionAuditViewer<AppDbContext>("/audit", o => o.RequireAuthorization("AuditViewers"));

That single registration mounts a JSON API (GET /audit/api/log, /audit/api/{type}/{key}, /audit/api/meta) plus a built-in UI served from /audit. No Blazor dependency, no build step — drops into any ASP.NET Core host.

The page itself is rendered server-side and HTML-encoded. It used to build its markup in the browser by concatenating audit values into innerHTML, so any value an attacker could write into an audited entity executed as script in the session of whoever reviewed the log — an administrator, by definition. Every audit value now passes through HtmlEncoder.Default on the way out, and the one script left in the page only writes textContent. The JSON API is unchanged and still returns raw values.

The access decision is mandatory. The viewer exposes every recorded change of every audited entity — other users' actions included, and values that redaction exists to protect — so it will not mount on an implicit "any authenticated user" rule. MapOrionAuditViewer throws InvalidOperationException at startup unless the registration states one of:

CallWho gets in
o.RequireAuthorization("AuditViewers")a policy you registered with AddAuthorization
o.RequireAuthorization(p => p.RequireRole("Auditor"))an inline policy
o.RequireAuthorization(p => p.RequireAuthenticatedUser())any authenticated user — stated deliberately
o.AllowAnonymous()everyone; local development only

Tenant filtering is honoured automatically: the API reads through db.AuditLog(), which applies the registered IAuditTenantResolver.

Benchmark — the honest story​

InterceptorBench (in-memory SQLite, .NET 10 — bench/Moongazing.OrionAudit.Bench):

ScenarioBatchMean (µs)RatioAllocated
SaveChanges_NoAudit12771.00×71 KB
SaveChanges_WithAudit17692.82×96 KB (1.35×)
SaveChanges_WithAsyncAudit11 3114.80×95 KB (1.34×)
SaveChanges_NoAudit109571.00×141 KB
SaveChanges_WithAudit103 9364.18×335 KB (2.37×)
SaveChanges_WithAsyncAudit103 4143.62×343 KB (2.43×)
SaveChanges_NoAudit1006 0231.00×819 KB
SaveChanges_WithAudit10013 7202.36×2.7 MB (3.31×)
SaveChanges_WithAsyncAudit10014 2592.45×2.8 MB (3.45×)

In-memory SQLite is unkind to async-mode bookkeeping — the ExecuteUpdateAsync claim plus the queue insert show up as raw cost without the network round-trip latency a real DB has. On a production SQL Server or Postgres, two things change in async mode's favour: the baseline SaveChanges carries network IO that absorbs sync mode's diff CPU into a much larger denominator, and the deferred SnapshotCursor lookup (a per-update DB query inside the consumer's transaction) genuinely leaves the hot path. Treat async capture as a correctness-preserving way to move materialisation off the consumer's transaction; it's a throughput feature, not a microbenchmark win.

The capture-queue depth is exposed as orionaudit.capture.queue_depth (observable gauge) so operators can watch dispatch lag in their dashboards.


Core Features​

Auto-capture on every SaveChanges​

AuditSaveChangesInterceptor registers itself in EF Core's interceptor pipeline. For every [Auditable] entity in EntityState.Added | Modified | Deleted, it writes one AuditLog row inside the same transaction as the originating change.

ctx.Orders.Add(new Order { Status = "Pending" });
await ctx.SaveChangesAsync();
// → AuditLog row: Action=Inserted, EntityId=..., Diff=[{"op":"add","path":"/Status","value":"Pending"}]

JSON Patch (RFC 6902) diffs​

Diffs are computed by OrionAudit's in-house, reflection-free RFC 6902 engine and stored in the Diff column as compact JSON. They are replayable — that's what makes time-travel reconstruction possible.

order.Status = "Shipped";
await ctx.SaveChangesAsync();
// → Diff = [{"op":"replace","path":"/Status","value":"Shipped"}]

Sensitive-field handling​

Attributes and equivalent fluent overrides — both control what lands in the audit table without touching the entity class beyond the attribute.

[Auditable]
public sealed class Customer
{
public Guid Id { get; set; }
public string Name { get; set; } = "";

[HashedAudit] public string Email { get; set; } = ""; // SHA-256 hex
[RedactedAudit] public string ApiKey { get; set; } = ""; // literal "<redacted>"
[NotAuditable] public string Internal { get; set; } = ""; // omitted entirely
}

// Equivalent fluent form:
services.AddOrionAudit<AppDbContext>(o => o
.Audit<Customer>(b => b
.Hash(c => c.Email)
.Redact(c => c.ApiKey)
.Exclude(c => c.Internal)));

Hash is deterministic, so equality checks on the audit table still work without leaking plaintext. Redact replaces the value with the literal "<redacted>" — change detection breaks on purpose for fields where even the existence of a change is sensitive.

Multi-tenant capture and read-side filter​

Implement IAuditTenantResolver once; every audit row gets the tenant id stamped, and every AuditFor<T>() query auto-filters to the current tenant.

public sealed class CurrentTenantResolver : IAuditTenantResolver
{
private readonly ITenantContext context;
public CurrentTenantResolver(ITenantContext context) => this.context = context;
public string? Resolve(IServiceProvider sp) => context.TenantId;
}

services.AddOrionAudit<AppDbContext>(o => o
.Audit<Order>()
.TenantResolver<CurrentTenantResolver>());

// Reads automatically scoped to current tenant
var rows = await context.AuditFor<Order>().ToListAsync();

// Need the global view for admin tooling?
var allRows = await context.AuditFor<Order>(crossTenant: true).ToListAsync();

An unresolved tenant narrows, it does not widen. If the resolver is registered but returns null — a dropped header, a claim the gateway did not forward, a background thread with no ambient context — the read is scoped to the no-tenant stream (TenantId null or ""), which in any tenant-stamped deployment is the empty set. Before v1.0.0 the filter fell through unfiltered and handed back every tenant's rows at exactly the moment the caller's identity was unknown. If you upgrade and a query that used to return rows now returns none, that is this: your resolver is returning null on that path and previously nobody noticed. A genuinely single-tenant deployment whose resolver returns null by design still reads its own history unchanged, and an application with no resolver registered at all is unaffected. Note that this is a narrowed read, not a refusal: it yields an empty result rather than a throw — these extensions sit on request paths — and rows that were never tenant-stamped are still returned. Do not rely on it to deny; rely on it not to leak. crossTenant: true remains the explicit, auditable way to read across tenants.

Time-travel reconstruction​

IAuditReconstructor replays the audit history of an entity up to any timestamp.

var reconstructor = serviceProvider.GetRequiredService<IAuditReconstructor>();

// Single entity
var orderAsOfYesterday = await reconstructor.ReconstructAsync<Order>(
entityId: order.Id.ToString(),
asOf: DateTime.UtcNow.AddDays(-1));

// Batch — one query, then per-entity replay
var manyAsOf = await reconstructor.ReconstructManyAsync<Order>(
entityIds: orderIds.Select(id => id.ToString()),
asOf: DateTime.UtcNow.AddHours(-3));

Returns null if the entity didn't exist or was deleted at that timestamp.

Reconstruction is tenant-scoped, through the same filter AuditFor<T>() uses, so it also returns null for an id whose rows belong to another tenant. When a registered resolver cannot name a tenant, the replay is scoped to the no-tenant stream like any other read — so it still reconstructs entities from rows that were never tenant-stamped (a single-tenant deployment reconstructs its history unchanged), and returns null only for entities whose rows are tenant-stamped. It is a scoped read, not a blanket denial; do not rely on it to refuse. Before v1.0.0 it read the audit table directly and replayed every tenant's history for the requested id into one object — which handed tenant A's values to tenant B and produced an entity that had existed in no tenant. There is deliberately no crossTenant escape hatch here: a cross-tenant replay is not a wider read but an incorrect entity. Read across tenants with AuditFor<T>(crossTenant: true), which returns rows rather than merging them.

User attribution via ASP.NET Core​

dotnet add package OrionAudit.AspNetCore
builder.Services
.AddOrionAudit<AppDbContext>(o => o
.Audit<Order>()
.UserResolver<HttpContextAuditUserResolver>())
.AddOrionAuditAspNetCore();

HttpContextAuditUserResolver pulls the user from HttpContext.User via the NameIdentifier / sub claim and populates AuditLog.UserId / UserDisplay. Anonymous requests leave those columns null without breaking the capture.

Attribution and AddDbContextPool / AddDbContextFactory​

UseOrionAudit(sp) keeps the provider EF Core hands the options lambda, and EF Core builds those options with a different lifetime per registration. That decides whether your resolvers can see the request:

RegistrationDbContextOptions lifetimeWhat sp isWhat you do
AddDbContext<T>((sp, o) => …)scopedthe request scopenothing — attribution just works
AddDbContextPool<T> / AddPooledDbContextFactory<T>singletonthe root providerpush the request scope (below)
AddDbContextFactory<T>singleton by defaultthe root providerpush the request scope, or pass lifetime: ServiceLifetime.Scoped
AddDbContext<T>(o => …) (no sp)——not supported; there is no provider to hand in

With the singleton rows, the lambda runs once, so a scoped IAuditUserResolver / IAuditTenantResolver resolved from that captured provider is the first request's instance for the life of the process — every audit row would name the first request's user and tenant, while the diffs stayed correct. Make the request scope ambient and capture uses it instead:

// once, before the endpoints
app.Use(async (http, next) =>
{
using (AuditScope.PushServices(http.RequestServices))
{
await next();
}
});

AuditScope.PushServices flows on AsyncLocal, wins over the captured provider on both the write path and the read-side tenant filter, and works the same way around a background job or console unit of work — push the scope you created for it.

If you use pooling, register a resolver, and push nothing, OrionAudit refuses: the first audited save throws OrionAuditConfigurationException naming these fixes, rather than write a trail that looks healthy and names the wrong person. The read path refuses from the same guard: a tenant-scoped read (AuditFor<T>(), AuditLog(), reconstruction, the viewer's API) throws the same exception under the same wiring, so a pooled registration cannot mean one thing to a write and another to a read. crossTenant: true is an explicit opt-out of tenant scoping and is unaffected, as is a single-tenant app with no resolver registered. Non-pooled AddDbContextFactory cannot be detected this way (Microsoft DI does not let a library tell its root provider apart from a scope), so run with ValidateScopes enabled in Development — it is what makes that case fail loudly.

OpenTelemetry instrumentation​

Spans and metrics are emitted under the OrionAudit ActivitySource and Meter.

builder.Services
.AddOpenTelemetry()
.WithTracing(t => t.AddSource(OrionAuditTelemetry.ActivitySourceName))
.WithMetrics(m => m.AddMeter(OrionAuditTelemetry.MeterName));
SignalTypeDescription
OrionAudit.CaptureActivityOne span per SaveChanges that wrote audit rows
OrionAudit.ReconstructActivityOne span per ReconstructAsync call
OrionAudit.ReconstructManyActivityOne span per ReconstructManyAsync call
orionaudit.entries.writtenCounterAudit rows successfully written
orionaudit.entries.failedCounterAudit rows written with diff errors
orionaudit.capture.durationHistogramInterceptor capture duration in milliseconds
orionaudit.reconstruct.durationHistogramReconstruction duration in milliseconds

Source-generated registration (AOT-aware)​

Skip the runtime assembly scan entirely. Decorate a partial class with [OrionAuditModule] and the bundled source generator emits a RegisterAuditedTypes method that registers every [Auditable] type discovered at compile time.

[OrionAuditModule]
public partial class AppAuditModule { }

// A hand-written System.Text.Json context covering your audited entities
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(Customer))]
public partial class AppJsonContext : JsonSerializerContext { }

services.AddOrionAudit<AppDbContext>(o =>
{
AppAuditModule.RegisterAuditedTypes(o.ConfigurationBuilder); // generator-emitted, no reflection
o.UseJsonContext(AppJsonContext.Default); // trim-aware snapshot serialisation
});

The generator ships inside the OrionAudit NuGet (analyzers/dotnet/cs/) — no extra package to install. The reflective ScanAssembly path still works and now carries [RequiresUnreferencedCode] so trim/AOT publishes flag it. As of v0.4.0 the diff engine is in-house and fully reflection-free. The snapshot-capture path is Native-AOT clean when wired through UseJsonContext; without a context it falls back to reflective serialization, which is annotated with [RequiresUnreferencedCode] / [RequiresDynamicCode] so trim/AOT publishes flag it. A CI Native-AOT probe publishes the context-wired surface and fails the build on any trim/AOT warning.

Framework-agnostic test helpers​

dotnet add package OrionAudit.Testing
using Moongazing.OrionAudit.Testing;

ctx.Orders.Add(new Order { Status = "Pending" });
await ctx.SaveChangesAsync();

AuditCapture.From(ctx)
.Should()
.HaveLogged<Order>(AuditAction.Inserted)
.HaveLoggedExactly(1).Of<Order>();

OrionAudit.Testing throws plain exceptions on failure, so it works with xUnit, NUnit, MSTest, or any other runner — no transitive FluentAssertions / Shouldly choice forced on you. InMemoryAuditUserResolver and InMemoryAuditTenantResolver round out the test-doubles surface.


Benchmarks​

See benchmarks.md for the full BenchmarkDotNet run, environment, and per-scenario interpretation (snapshot build, JSON Patch compute vs. apply, EF Core SaveChanges overhead, time-travel reconstruction). Headline numbers from the last measured run on an Intel i7-7820HQ (Kaby Lake), .NET 10.0.5, BenchmarkDotNet 0.15.8:

  • Snapshot build of a 7-property entity: ~677 ns, ~984 B allocated.
  • JSON Patch compute on 16 properties: ~96 us, ~88 KB.
  • JSON Patch apply on the same diff: ~36 us, ~15 KB (about 5x cheaper than compute).
  • SaveChanges overhead on in-memory Sqlite: 3.5x for single-row, 4.2x for 100-row batches; drops into the 5-15 percent range on a real DB where round-trip dominates.
  • Reconstruction at depth 1000: ~9 ms, ~4.3 MB (O(N) without snapshotting).

Reproduce with dotnet run -c Release --project bench/Moongazing.OrionAudit.Bench.


Sample Application​

dotnet run --project sample/Moongazing.OrionAudit.Sample.Console

The sample walks through the features end-to-end against an in-memory Sqlite DB: insert / update / delete cycles, sensitive-field masking, multi-tenant filtering, time-travel reconstruction, live OpenTelemetry activity capture, periodic snapshotting, soft-delete capture, and the v0.8.0 queryable history read API plus snapshot compaction. Each section prints what just happened so you can scan the output instead of reading source.


Documentation​


More from the Orion family​

OrionAudit is one of a set of standalone .NET libraries:

  • OrionGuard - guard clauses, validation, DDD primitives.
  • OrionKey - source-generated strongly-typed IDs.
  • OrionLock - distributed locking.
  • OrionPatch - transactional outbox for EF Core (enqueue inside SaveChanges, dispatch at-least-once through a pluggable sink).
  • OrionVault - column-level transparent data encryption at rest for EF Core.

See it in a real app​

Moongazing.OrionShowcase is a production-shaped banking sample integrating all six Orion packages end-to-end. OrionAudit captures Account/Customer/Transaction entity diffs automatically via SaveChangesInterceptor. Concrete usage in the showcase:


Contributing​

Issues and pull requests welcome. Please read CONTRIBUTING.md and the Code of Conduct before opening one.

License​

This project is licensed under the MIT License.

Author​

Tunahan Ali Ozturk — GitHub — published on NuGet as Moongazing.

Packages​

PackageVersionDownloads
OrionAudit2.0.010,149
OrionAudit.Testing2.0.05,617
OrionAudit.AspNetCore2.0.05,572
OrionAudit.Viewer2.0.05,170
OrionAudit.MySql2.0.04,286