OrionAudit
OrionAudit
EF Core change-audit trail with JSON Patch diffs, multi-tenant support, and time-travel reconstruction
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 underUseLazyLoadingProxies(), 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 witho.UseHashChain(h => h.UseKey(...))and every capturedAuditLogrow gains a keyed HMAC-SHA256EntryHashthat 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.VerifyChainAsyncwalks 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?
| Feature | OrionAudit | Audit.NET | EFCore.Triggered | DIY pattern |
|---|---|---|---|---|
EF Core SaveChanges interception | Yes | Yes | Yes | Yes |
| JSON Patch (RFC 6902) diffs | Yes | Yes | - | - |
| Time-travel reconstruction | Yes | - | - | - |
| Sensitive-field attributes (Hash/Redact) | Yes | - | - | - |
| Multi-tenant read-side filter | Yes | - | - | - |
| Pluggable user / tenant resolvers | Yes | Yes | - | Yes |
| ASP.NET Core HttpContext resolver | Yes | Yes | - | - |
OpenTelemetry ActivitySource + Meter | Yes | - | - | - |
| Framework-agnostic test helpers | Yes | - | - | - |
| Multi-targets net8 / net9 / net10 | Yes | Yes | Yes | n/a |
| Source-generated type discovery | Yes | - | - | - |
| NativeAOT clean | Yes | - | - | - |
| Composite primary key support | Yes | Yes | Yes | Yes |
| Periodic snapshotting (O(K) replay) | Yes | - | - | - |
| Retention policy + background sweep | Yes | - | - | - |
| 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 API | Yes | - | - | - |
| 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
| Package | Install | Purpose |
|---|---|---|
OrionAudit | dotnet add package OrionAudit | Core library — interceptor, diff, reconstruction |
OrionAudit.AspNetCore | dotnet add package OrionAudit.AspNetCore | HttpContextAuditUserResolver + DI helpers |
OrionAudit.MySql | dotnet add package OrionAudit.MySql | MySQL / MariaDB provider (ApplyOrionAuditMySqlConfigurations, JSON/LONGTEXT columns) |
OrionAudit.Viewer | dotnet add package OrionAudit.Viewer | Embedded read-only audit-trail UI (MapOrionAuditViewer) |
OrionAudit.Testing | dotnet add package OrionAudit.Testing | AuditCapture + 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 ownBEGINon the connection's busy timeout (30 seconds by default) and then reads the head the first one committed. OneSaveChangesAsyncper writer is enough — no retry loop. The exception is a shared-cache in-memory database (mode=memory&cache=shared), which serializes with table locks reportingSQLITE_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
Hand both stampPreviousHash = H. That is a fork, not a mis-ordering:ChainSequenceorders a chain, it cannot repair one that branched, andVerifyChainAsyncwill reportBrokenLinkon 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
ExecuteDeletefast path is off. The chain repair has to know which streams lost which rows, which a bareExecuteDeletenever reveals, so the sweep materialises each batch instead. The batch is already bounded byMaxRowsPerSweep. 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_Queuerow in the same transaction as the data change — capture stays atomic and lossless. AuditDispatcherHostedServicepolls the queue, computes diffs, and writes the finalAuditLogrows. Inserts and deletes commit together → exactly-once.- A row that throws is retried up to
MaxAttemptsand then dead-lettered (Errorcolumn set; surfaced viaorionaudit.dispatch.rows_deadletteredtelemetry). 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:
| Call | Who 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):
| Scenario | Batch | Mean (µs) | Ratio | Allocated |
|---|---|---|---|---|
SaveChanges_NoAudit | 1 | 277 | 1.00× | 71 KB |
SaveChanges_WithAudit | 1 | 769 | 2.82× | 96 KB (1.35×) |
SaveChanges_WithAsyncAudit | 1 | 1 311 | 4.80× | 95 KB (1.34×) |
SaveChanges_NoAudit | 10 | 957 | 1.00× | 141 KB |
SaveChanges_WithAudit | 10 | 3 936 | 4.18× | 335 KB (2.37×) |
SaveChanges_WithAsyncAudit | 10 | 3 414 | 3.62× | 343 KB (2.43×) |
SaveChanges_NoAudit | 100 | 6 023 | 1.00× | 819 KB |
SaveChanges_WithAudit | 100 | 13 720 | 2.36× | 2.7 MB (3.31×) |
SaveChanges_WithAsyncAudit | 100 | 14 259 | 2.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:
| Registration | DbContextOptions lifetime | What sp is | What you do |
|---|---|---|---|
AddDbContext<T>((sp, o) => …) | scoped | the request scope | nothing — attribution just works |
AddDbContextPool<T> / AddPooledDbContextFactory<T> | singleton | the root provider | push the request scope (below) |
AddDbContextFactory<T> | singleton by default | the root provider | push 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));
| Signal | Type | Description |
|---|---|---|
OrionAudit.Capture | Activity | One span per SaveChanges that wrote audit rows |
OrionAudit.Reconstruct | Activity | One span per ReconstructAsync call |
OrionAudit.ReconstructMany | Activity | One span per ReconstructManyAsync call |
orionaudit.entries.written | Counter | Audit rows successfully written |
orionaudit.entries.failed | Counter | Audit rows written with diff errors |
orionaudit.capture.duration | Histogram | Interceptor capture duration in milliseconds |
orionaudit.reconstruct.duration | Histogram | Reconstruction 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
- Roadmap — forward plan. The API freeze it aimed at has happened: v1.0.0 declared the surface stable and v2.0.0 promoted the corrected baseline into
PublicAPI.Shipped.txt, where the analyzer enforces it. Still ahead: a separate audit store and AOT polish. - Contributing guide
- Design spec
- v0.1.0 implementation plan
- Sample console:
sample/Moongazing.OrionAudit.Sample.Console - Benchmarks:
bench/Moongazing.OrionAudit.Bench
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:
- src/Moongazing.OrionShowcase.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
- src/Moongazing.OrionShowcase.Infrastructure/Persistence/BankingDbContext.cs
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
| Package | Version | Downloads |
|---|---|---|
| OrionAudit | 2.0.0 | 10,149 |
| OrionAudit.Testing | 2.0.0 | 5,617 |
| OrionAudit.AspNetCore | 2.0.0 | 5,572 |
| OrionAudit.Viewer | 2.0.0 | 5,170 |
| OrionAudit.MySql | 2.0.0 | 4,286 |