Skip to content

Entity audit trail has no per-entity opt-out: sensitive values are copied into AuditRecords #1389

Description

@marcelo-maciel

Problem

AuditingSaveChangesInterceptor audits every tracked entity of every DbContext registered through AddHeroDbContext, and the entity-change payload stores old and new values, not just the fact that a row changed. The only exclusion today is a hard-coded type check:

https://github.com/fullstackhero/dotnet-starter-kit/blob/main/src/Modules/Auditing/Modules.Auditing/Persistence/AuditingSaveChangesInterceptor.cs#L32

if (ctx is AuditDbContext) return result;

var entries = ctx.ChangeTracker.Entries()
    .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
    .ToArray();

For most entities that is exactly what you want from an audit trail. For a few, the values themselves are the sensitive payload the application promised to keep in one place only, and the audit row becomes a second, longer-lived copy of it in a table with different access rules.

Concrete case that made us hit this: a whistleblowing channel where the report body, the tracking protocol and the optional contact details must exist in a single table and nowhere else. Saving one report today copies all of it into auditing."AuditRecords" as PayloadJson, next to userId, traceId and requestId from the envelope, which is precisely the correlation the product promises not to keep. The same shape applies to health records, identity documents, and any secret held in a column.

What makes it easy to get wrong: [NoAudit] exists and looks like the opt-out, but it only governs HTTP activity auditing in AuditHttpMiddleware and has no effect on the entity diff. A reader who applies it is likely to believe the problem is handled when it is not.

What we are running

A marker interface in Modules.Auditing.Contracts and one Where in the interceptor:

namespace FSH.Modules.Auditing.Contracts;

/// <summary>
/// Opt-out marker for the entity-change audit trail. An entity implementing this is skipped by the
/// auditing SaveChanges interceptor, so no property-level diff of it is ever captured or stored.
/// </summary>
public interface IAuditExempt;
var entries = ctx.ChangeTracker.Entries()
    .Where(e => e.Entity is not IAuditExempt)
    .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
    .ToArray();

Per entity rather than per DbContext on purpose: a module normally wants its operational tables audited and only the sensitive ones exempt. In our module the access-control tables stay audited (who granted access to whom is governance evidence) while the report entities are exempt.

It is opt-in, so no existing behaviour changes for anyone who does not implement the interface.

Covered by an integration test that writes an exempt entity and a non-exempt one, waits for the audit row of the non-exempt entity as a positive control (the publisher is a background channel, so an empty table alone proves nothing), and then asserts no payload carries the exempt entity's values. Removing the Where makes that test fail.

Alternatives we considered and why we did not take them

  • Field-level redaction in EntityDiffBuilder. Depends on remembering to annotate every new property. A column added later leaks silently, which is the worst failure mode for this class of data.
  • Excluding the whole DbContext. Loses auditing for the module's operational tables, which we want audited.
  • A second interceptor registered only for that context. The interceptors come from DI and apply to every context built by AddHeroDbContext, so opting out would mean bypassing the shared registration path.
  • Reusing [NoAudit]. Different mechanism, different pipeline stage. Overloading it would make both harder to reason about.

Question

@iammukeshm, does this look like the right shape to you, and would you take a PR for it? Happy to adjust the naming (IAuditExempt vs something like INotAudited), the placement, or to add documentation alongside [NoAudit] explaining which mechanism covers which pipeline. If you would rather solve it differently, say the word and we will follow your design instead of carrying a local patch.

For context, we have been running this on our fork since 2026-09-15.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions