Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@
All notable changes to this project are documented here.
This project follows [Semantic Versioning](https://semver.org/).

## [1.0.0-preview.2] — Unreleased

### Added
- **`IgnoreEntity<TEntity>()` and `IgnoreEntities(params Type[])`** on the builder — a class-level
`[AuditIgnore]` for a type the application cannot annotate. The case it exists for is
DotNetBoost.MultiLingual's `Translation`, which shares the application's `DbContext`, is saved
beside every translated entity, and is keyed on four columns: until now it made
`UseAuditing(sp)` unusable beside `UseMultiLingual(sp)`.
- The same rule as the attribute: suppressed and reported as `EntityIgnored`, ahead of
`[Audited]` and `RequireOptIn`, subclasses included, and a write addressed by name —
`audit.For("Translation", id)` — suppressed too.
- Held in the options rather than in `AuditPlan`, which caches what a type's attributes say;
an ignore is configuration, like `RequireOptIn`.
- `Build()` refuses a type that is both declared (`DeclareEntity`, `DeclareEntities`, or an
assembly scan) and ignored, and an ignored type that shares a declared entity's stored name.
So an ignored type never appears in `GET /api/audit/entities`.
- Named `IgnoreEntity` rather than `Ignore` because EF Core's `modelBuilder.Ignore<T>()` means
"do not map this type", and the two would sit a few lines apart.

### Fixed
- **An excluded entity with a multi-column key no longer throws on save.** The interceptor reported
the skip with the entity's key, and reading a multi-column key throws — so a class-level
`[AuditIgnore]` on such an entity, which the refusal's own message recommends, failed every save
anyway, as did one that `RequireOptIn` left out. A skipped entity is now reported with an empty
key when its key spans several columns: the library never composes one, and empty is what a
report already says for an entity skipped before it was addressed — as it is for a single key
too long for the column, which threw the same way. An entity with a multi-column
key that is *not* excluded is still refused, and the message now names `IgnoreEntity<T>()`
alongside `[AuditIgnore]`.

## [1.0.0-preview.1] — 2026-10-23

The first published build, and a preview rather than a release: nothing outside this repository has
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,27 @@ services.AddAuditing(o =>
.Build();
```

**Excluding an entity.** `[AuditIgnore]` on a class keeps it out of the trail. For a type you do not
own and so cannot annotate, say the same thing at registration with `.IgnoreEntity<T>()`. The case
it was made for is DotNetBoost.MultiLingual, whose `Translation` rows share your `DbContext`, are
saved beside every translated entity, and are keyed on four columns, which an audit entry cannot
address:

```csharp
services.AddDbContext<AppDbContext>((sp, o) => o.UseNpgsql(cs).UseMultiLingual(sp).UseAuditing(sp));

services.AddAuditing()
.UseEntityFrameworkCore<AppDbContext>()
.DeclareEntitiesFrom(typeof(Product).Assembly)
.IgnoreEntity<Translation>() // MultiLingual's row: saved, never audited
.Build();
```

It is the attribute's rule exactly: it beats `[Audited]` and `RequireOptIn`, and the skip is
reported to the [observer](docs/observability.md) as `EntityIgnored`. It is not called `Ignore`
because EF Core's `modelBuilder.Ignore<T>()` means "do not map this type"; this one leaves it mapped
and saved. `Build()` refuses a type that is both declared and ignored.

| I want to… | Guide |
|---|---|
| Record from Dapper or MongoDB, and know when each becomes durable | [Stores](docs/stores.md) |
Expand Down
5 changes: 4 additions & 1 deletion docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ reads the declared entities and refuses a configuration with no store.
| `.DeclareEntitiesFrom(assembly)` | Core | Declares every public class in `assembly` carrying `[AuditEntity]`, `[Audited]` or `[AuditRetention]` |
| `.DeclareEntity<TEntity>()` | Core | Declares one entity, attribute or not |
| `.DeclareEntities(params Type[])` | Core | Declares several |
| `.IgnoreEntity<TEntity>()` | Core | Never records `TEntity` — a class-level `[AuditIgnore]` for a type you cannot annotate, such as MultiLingual's `Translation`. Wins over `[Audited]` and `RequireOptIn`. Not `Ignore`, which in EF Core means "do not map" |
| `.IgnoreEntities(params Type[])` | Core | Ignores several |
| `.WithRetention(TimeSpan)` | Core | Same as `AddAuditing(o => o.Retention = …)`. Deletes nothing by itself |
| `.WithPageSize(defaultSize, maxSize)` | Core | Both page sizes at once |
| `.WithMaxValueLength(characters)` | Core | Override the 4000-character value limit |
Expand All @@ -39,7 +41,8 @@ written to one are invisible to the other, and a trail split across two stores i

`Build()` also refuses two combinations the setters cannot catch on their own —
`DefaultPageSize` above `MaxPageSize`, and `MaxValueLength` above `AuditingOptions.MaxValueCeiling`
(1 MB of UTF-16) — and two declared entities claiming one stored name.
(1 MB of UTF-16) — two declared entities claiming one stored name, and an `IgnoreEntity` type that
is also declared or shares a declared entity's stored name.

## `AuditingOptions`

Expand Down
10 changes: 8 additions & 2 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,14 @@ key if its history matters.
## Composite keys are refused

An entry records a single key. An entity with a composite primary key throws when it is saved, with
the three ways out in the message: a surrogate key, `[AuditIgnore]`, or recording by hand with
`audit.For(name, id)` and a key you compose yourself.
the ways out in the message: a surrogate key, `[AuditIgnore]` — or `.IgnoreEntity<T>()` at
registration for a type you cannot annotate — or recording by hand with `audit.For(name, id)` and a
key you compose yourself.

An excluded entity's key is never read, so excluding it is enough: DotNetBoost.MultiLingual's own
`Translation` row is keyed on four columns, and `.IgnoreEntity<Translation>()` is what lets the
interceptor run in a context that saves one. Its skip is reported to the observer with an empty
`entityId`, because the library does not compose a multi-column key even for a report.

`DotNetBoost.Media` and `DotNetBoost.MultiLingual` refuse composite keys for the same reason, so an
entity that cannot be audited cannot be given media or translations either.
Expand Down
3 changes: 2 additions & 1 deletion docs/scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ plain text, forever, in a table nobody thinks to look at.

- `[AuditIgnore]` on a property — never recorded, not even its name.
- `[AuditRedact]` on a property — recorded as changed, both values `null`, `IsRedacted` set.
- `[AuditIgnore]` on a class — the entity is not audited at all.
- `[AuditIgnore]` on a class — the entity is not audited at all. `.IgnoreEntity<T>()` at
registration is the same rule for a type the application cannot annotate.
- Redaction follows **the attribute**, never whether the value happened to be encrypted at rest, so
it holds with no encryptor configured. This is the rule Settings' observer got right.
- An opt-in **allow-list mode** (`AuditOnly`) for the entities that want it, because the sane
Expand Down
54 changes: 52 additions & 2 deletions src/DotNetBoost.Auditing.Core/AuditingInfrastructure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ public AuditingBuilder(IServiceCollection services)
/// </summary>
/// <returns>The configured services, so a caller can carry on with them.</returns>
/// <exception cref="InvalidOperationException">
/// No store is configured, the options contradict each other, or two declared entities claim
/// the same stored name.
/// No store is configured, the options contradict each other, two declared entities claim
/// the same stored name, or an entity excluded with <c>IgnoreEntity</c> is also declared.
/// </exception>
public IServiceCollection Build()
{
Expand Down Expand Up @@ -95,9 +95,59 @@ private AuditModelRegistry BuildRegistry()
declared[name] = type;
}

RefuseIgnoredDeclarations(declared);

return declared.Count == 0 ? AuditModelRegistry.Empty : new AuditModelRegistry(declared);
}

/// <summary>
/// Refuses an <c>IgnoreEntity&lt;T&gt;()</c> that contradicts a declaration: the same type both
/// declared and ignored, or an ignored type sharing a declared entity's stored name.
/// </summary>
/// <remarks>
/// The first is two instructions that cannot both be followed — declared says "apply this type's
/// rules", ignored says "never record it" — and either reading would surprise someone, so the
/// library asks rather than picks. The second matters for a write addressed by name: with an
/// ignored and a declared type behind one name, <c>audit.For(name, id)</c> would be recorded
/// under one type's rules or suppressed under the other's ignore, chosen by nothing the caller
/// can see. It is the same refusal as two declared types under one name.
/// <para>
/// Also what keeps an ignored type out of the declared-entities listing
/// (<c>GET /api/audit/entities</c>): the listing is this registry, and an ignored type cannot be
/// in it.
/// </para>
/// </remarks>
/// <exception cref="InvalidOperationException">An ignored type is declared, or shares a declared name.</exception>
private void RefuseIgnoredDeclarations(Dictionary<string, Type> declared)
{
foreach (var ignored in Options.IgnoredEntities)
{
foreach (var (name, type) in declared)
{
// IsAssignableFrom rather than ==, because a subclass of an ignored type is ignored
// too, and declaring one would be the same contradiction one level down.
if (ignored.IsAssignableFrom(type))
{
throw new InvalidOperationException(
$"'{type.FullName}' is both declared and excluded with IgnoreEntity" +
(type == ignored ? "" : $"<{ignored.Name}>, its base") +
". Declaring an entity applies its rules and ignoring it records nothing, so " +
"one of the two is a mistake: remove the IgnoreEntity call, or stop declaring " +
"the type (an assembly scan declares every class carrying [AuditEntity], " +
"[Audited] or [AuditRetention]).");
}

if (string.Equals(AuditEntityNames.For(ignored), name, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"'{ignored.FullName}' is excluded with IgnoreEntity, and '{type.FullName}' is declared " +
$"under the same entity name '{name}'. A write addressed by that name could be " +
"either. Give the declared one a distinct [AuditEntity(\"…\")] name.");
}
}
}
}

/// <summary>
/// The types in <paramref name="assembly"/> worth declaring: a concrete, public class that
/// carries any of the library's attributes. Scanning every class would put every DTO and
Expand Down
55 changes: 55 additions & 0 deletions src/DotNetBoost.Auditing.Core/AuditingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,61 @@ public TimeSpan? Retention
}
}

private readonly List<Type> _ignoredEntities = [];

/// <summary>
/// The entity types excluded by <c>IgnoreEntity&lt;T&gt;()</c> — a class-level
/// <c>[AuditIgnore]</c> for a type the application cannot annotate.
/// <para>
/// Here rather than in <see cref="AuditPlan"/>, which is a static cache of what a type's
/// attributes say: an ignore is configuration, and two hosts reading the same assembly can
/// differ about it, exactly as they can about <see cref="RequireOptIn"/>.
/// </para>
/// </summary>
internal IReadOnlyList<Type> IgnoredEntities => _ignoredEntities;

/// <summary>Adds <paramref name="entityType"/> to <see cref="IgnoredEntities"/>, once.</summary>
internal void Ignore(Type entityType)
{
if (!_ignoredEntities.Contains(entityType))
_ignoredEntities.Add(entityType);
}

/// <summary>
/// Whether <paramref name="entityType"/> is excluded by <c>IgnoreEntity&lt;T&gt;()</c>. A
/// subclass of an ignored type is ignored with it, as it would be by an inherited
/// <c>[AuditIgnore]</c> — which is also what keeps an EF Core proxy of an ignored entity out.
/// </summary>
internal bool IsIgnored(Type entityType)
{
// A list rather than a set, and a loop rather than a lookup: the subclass rule needs
// IsAssignableFrom, and an application ignores one or two types, not hundreds.
foreach (var ignored in _ignoredEntities)
{
if (ignored.IsAssignableFrom(entityType))
return true;
}

return false;
}

/// <summary>
/// Whether the entity stored under <paramref name="entityName"/> is excluded by
/// <c>IgnoreEntity&lt;T&gt;()</c>, for a write addressed by name — the case where there is no
/// type to ask about. <c>Build()</c> refuses an ignored type whose name a declared entity also
/// uses, so the name is unambiguous here.
/// </summary>
internal bool IsIgnored(string entityName)
{
foreach (var ignored in _ignoredEntities)
{
if (string.Equals(AuditEntityNames.For(ignored), entityName, StringComparison.Ordinal))
return true;
}

return false;
}

/// <summary>
/// Validates the combination. Called by <c>Build()</c>; the individual setters have already
/// rejected their own bad values, so this is only for the pairs that are each valid alone.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,78 @@ public static AuditingBuilder DeclareEntities(this AuditingBuilder builder, para
return builder;
}

/// <summary>
/// Never records <typeparamref name="TEntity"/> — a class-level <c>[AuditIgnore]</c>, for a
/// type the application cannot annotate because it does not own it.
/// <code>
/// services.AddAuditing()
/// .UseEntityFrameworkCore&lt;AppDbContext&gt;()
/// .DeclareEntitiesFrom(typeof(Product).Assembly)
/// .IgnoreEntity&lt;Translation&gt;() // DotNetBoost.MultiLingual's row
/// .Build();
/// </code>
/// <para>
/// The case it exists for is a sibling library's entity in the application's own
/// <c>DbContext</c>: DotNetBoost.MultiLingual's <c>Translation</c> is saved beside every
/// translated entity, is keyed on four columns, and comes from a package the application
/// cannot add an attribute to. A trail of it would also repeat what the translated entity's
/// own history should say.
/// </para>
/// <para>
/// The same rule as the attribute, in every respect: the change is not recorded and is
/// reported to the observer as <c>EntityIgnored</c>; it wins over <c>[Audited]</c> and over
/// <c>RequireOptIn</c>; a subclass is ignored with its base; and a write addressed by name,
/// <c>audit.For("Translation", id)</c>, is suppressed as well, because the ignore names the
/// type as surely as a declaration would.
/// </para>
/// <para>
/// Named <c>IgnoreEntity</c> rather than <c>Ignore</c> because EF Core's
/// <c>modelBuilder.Ignore&lt;T&gt;()</c> already means something else — "this type is not
/// mapped" — and the two would sit a few lines apart in the same application. This one
/// leaves the type mapped and saved, and only keeps it out of the trail.
/// </para>
/// </summary>
/// <typeparam name="TEntity">The entity type.</typeparam>
/// <param name="builder">The builder.</param>
/// <returns>The builder, for chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <c>null</c>.</exception>
/// <remarks>
/// <c>Build()</c> refuses a type that is also declared — by <c>DeclareEntity</c>,
/// <c>DeclareEntities</c> or an assembly scan — and one whose stored name a declared entity
/// also uses. Declared means "apply this type's rules", ignored means "never record it", and
/// the library will not guess which was meant.
/// </remarks>
public static AuditingBuilder IgnoreEntity<TEntity>(this AuditingBuilder builder)
where TEntity : class
{
ArgumentNullException.ThrowIfNull(builder);

builder.Options.Ignore(typeof(TEntity));
return builder;
}

/// <summary>
/// Never records any of <paramref name="entityTypes"/>. See
/// <see cref="IgnoreEntity{TEntity}(AuditingBuilder)"/>.
/// </summary>
/// <param name="builder">The builder.</param>
/// <param name="entityTypes">The entity types.</param>
/// <returns>The builder, for chaining.</returns>
/// <exception cref="ArgumentNullException">Either argument, or one of the types, is <c>null</c>.</exception>
public static AuditingBuilder IgnoreEntities(this AuditingBuilder builder, params Type[] entityTypes)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(entityTypes);

foreach (var type in entityTypes)
{
ArgumentNullException.ThrowIfNull(type);
builder.Options.Ignore(type);
}

return builder;
}

/// <summary>
/// Keeps entries for <paramref name="retention"/>, for entities with no
/// <c>[AuditRetention]</c> of their own. Nothing is deleted until a prune runs.
Expand Down
2 changes: 2 additions & 0 deletions src/DotNetBoost.Auditing.Core/PublicApi.txt
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ namespace Microsoft.Extensions.DependencyInjection
public static DotNetBoost.Auditing.Core.AuditingBuilder DeclareEntitiesFrom(DotNetBoost.Auditing.Core.AuditingBuilder builder, System.Reflection.Assembly assembly);
public static DotNetBoost.Auditing.Core.AuditingBuilder DeclareEntity<TEntity>(DotNetBoost.Auditing.Core.AuditingBuilder builder);
public static DotNetBoost.Auditing.Core.AuditingBuilder DisableRecording(DotNetBoost.Auditing.Core.AuditingBuilder builder);
public static DotNetBoost.Auditing.Core.AuditingBuilder IgnoreEntities(DotNetBoost.Auditing.Core.AuditingBuilder builder, System.Type[] entityTypes);
public static DotNetBoost.Auditing.Core.AuditingBuilder IgnoreEntity<TEntity>(DotNetBoost.Auditing.Core.AuditingBuilder builder);
public static DotNetBoost.Auditing.Core.AuditingBuilder RequireOptIn(DotNetBoost.Auditing.Core.AuditingBuilder builder);
public static DotNetBoost.Auditing.Core.AuditingBuilder UseActorAccessor<TAccessor>(DotNetBoost.Auditing.Core.AuditingBuilder builder);
public static DotNetBoost.Auditing.Core.AuditingBuilder UseCustomStore(DotNetBoost.Auditing.Core.AuditingBuilder builder, System.Func<System.IServiceProvider, DotNetBoost.Auditing.Core.Interfaces.IAuditStore> store);
Expand Down
Loading
Loading