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.
Problem
AuditingSaveChangesInterceptoraudits every tracked entity of everyDbContextregistered throughAddHeroDbContext, 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
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"asPayloadJson, next touserId,traceIdandrequestIdfrom 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 inAuditHttpMiddlewareand 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.Contractsand oneWherein the interceptor:Per entity rather than per
DbContexton 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
Wheremakes that test fail.Alternatives we considered and why we did not take them
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.DbContext. Loses auditing for the module's operational tables, which we want audited.AddHeroDbContext, so opting out would mean bypassing the shared registration path.[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 (
IAuditExemptvs something likeINotAudited), 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.