DKNet

DKNet.EfCore.DataAuthorization

Row-level, ownership-based data authorization for EF Core — an automatic global query filter on reads plus SaveChanges-time owner stamping on writes.

✨ Why use it?

Reach for this package when rows belong to a principal — a tenant, a user, a branch, a department — and you want that rule enforced by the persistence layer rather than by convention in application code.

🚀 Quick Start

dotnet add package DKNet.EfCore.DataAuthorization

The package brings in DKNet.EfCore.Extensions (global query filter plumbing) and DKNet.EfCore.Hooks (SaveChanges pipeline) as project/package references — you don’t add those separately for this feature.

Minimum wiring, from the real signatures in EfCoreDataAuthSetup, DataOwnerAuthQuery, and SetupEfCoreHook:

// 1. Entity opts into ownership
public class Invoice : IOwnedBy
{
    public string OwnedBy { get; private set; } = string.Empty;
    // ... other members
}

// 2. DbContext exposes the current caller's access.
//    Implementing IDataOwnerDbContext is mandatory: AddDataOwnerProvider<TDbContext, TProvider>()
//    constrains TDbContext to it, so a context without the interface does not compile.
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options), IDataOwnerDbContext
{
    public IEnumerable<string> AccessibleKeys { get; init; } = [];
    // IsUnrestrictedAccess defaults to false via the interface — override only for admin/system contexts.
}

// 3. Provider supplies the current owner key and the caller's accessible keys
public sealed class TenantOwnerProvider(ICurrentTenant currentTenant) : IDataOwnerProvider
{
    public string? GetOwnershipKey() => currentTenant.TenantId;
    // Default GetAccessibleKeys() wraps GetOwnershipKey() into a single-key collection;
    // override it if a caller may see more than one key (see `IDataOwnerProvider.GetAccessibleKeys()` below).
}

// 4. Registration — UseAutoConfigModel is what attaches the global query filter to the model
services
    .AddDataOwnerProvider<AppDbContext, TenantOwnerProvider>()
    .AddDbContextWithHook<AppDbContext>(options =>
        options.UseSqlServer(connectionString)
               .UseAutoConfigModel<AppDbContext>());

AddDataOwnerProvider<TDbContext, TProvider>() is an extension(IServiceCollection) member on EfCoreDataAuthSetup (C# 14 extension members), declared where TDbContext : DbContext, IDataOwnerDbContext. Registering a DbContext that does not implement IDataOwnerDbContext is therefore a compile error at the call site rather than a runtime surprise — see the Migration Guide if you hit that error on upgrade. It:

  1. Registers DataOwnerAuthQuery as a global model builder (AddGlobalModelBuilder<DataOwnerAuthQuery>()) — a no-op if it’s already registered (checked via IsRegistered<IDataOwnerProvider>()).
  2. Registers TProvider as scoped IDataOwnerProvider.
  3. Registers DataOwnerHook as a keyed hook for TDbContext (AddHook<TDbContext, DataOwnerHook>()).

Two things it does not do for you, because they belong to the packages it builds on:

🧩 Features

IOwnedBy — ownership marker

public interface IOwnedBy
{
    string OwnedBy { get; }
}

Implement this on any entity that should be subject to ownership filtering and stamping. Only entities that implement IOwnedBy are touched by the filter or the hook — everything else in the model is unaffected. The getter-only shape signals intent: consumers should mutate OwnedBy through a domain method or a private setter, not assign it arbitrarily (the hook and its reassignment guard, below, assume that discipline).

Automatic global query filter (DataOwnerAuthQuery)

Registering the provider (see Quick Start) applies a global EF Core query filter to every entity type in the model that implements IOwnedBy (excluding TPH-discriminated subtypes — GetDiscriminatorValue() == null — since EF Core already applies a base type’s filter down the hierarchy). The filter, evaluated per query against your IDataOwnerDbContext:

x => capturedContext.IsUnrestrictedAccess
     || capturedContext.AccessibleKeys.Contains(((IOwnedBy)x).OwnedBy);

What this buys you: no repository, handler, or LINQ query anywhere in the app needs a Where(x => x.OwnedBy == ...) — every DbSet<T> query against an IOwnedBy entity is scoped automatically, translated to a SQL IN clause (see the gotcha in section 6 about why this only works because AccessibleKeys is IEnumerable<string>).

Key behaviors, verified from DataOwnerAuthQuery:

Ownership stamping and reassignment guard (DataOwnerHook)

DataOwnerHook implements IBeforeSaveHookAsync and runs inside the SaveChanges pipeline for every registered TDbContext (see section 5 for how the hook actually gets invoked). For every tracked entity it:

This saves you from writing that stamping/guard logic in every aggregate’s constructor or every command handler — it happens once, uniformly, for anything that implements IOwnedBy.

Who fills CreatedBy/UpdatedBy: the audit-field split

The audit fields are shared ground with DKNet.EfCore.AuditLogs, which can stamp them from the signed-in user instead. DataOwnerHook takes that package’s ICurrentUserProvider as an optional dependency and reads it once per save, before walking the entries:

For this save OwnedBy CreatedBy/CreatedOn/UpdatedBy/UpdatedOn
no ICurrentUserProvider registered this hook, from the ownership key this hook, from the ownership key — unchanged from before the provider existed
a provider registered but GetCurrentUser() returned null/empty this hook, from the ownership key this hook, from the ownership key — the same fallback, decided per save
GetCurrentUser() returned a value this hook, from the ownership key EfCoreAuditHook, from that value

Two consequences worth stating plainly:

Ownership itself is never delegated: OwnedBy is stamped by this hook and only by this hook, in every row above.

IDataOwnerProvider.GetAccessibleKeys() default

public ICollection<string> GetAccessibleKeys()
{
    var key = GetOwnershipKey();
    return string.IsNullOrEmpty(key) ? [] : [key];
}

Most providers only need to implement GetOwnershipKey() (the key stamped on new rows) — the default GetAccessibleKeys() wraps it into a single-key collection, which is also what DataOwnerHook uses for the reassignment guard. Override GetAccessibleKeys() directly when a caller can legitimately see/write more than one key — e.g. a head-office user who spans several branch keys.

Note this is a different member than IDataOwnerDbContext.AccessibleKeys (see Automatic global query filter above): the provider supplies the data used by both the DbContext’s AccessibleKeys property (your implementation typically just forwards _provider.GetAccessibleKeys()) and the hook’s reassignment guard — the DbContext is what the query filter reads.

⚙️ Configuration reference

There is no appsettings.json-driven configuration — everything is expressed through the three interfaces you implement:

Setting Where Default Effect
IDataOwnerDbContext.IsUnrestrictedAccess your DbContext false (interface default) false: rows restricted to AccessibleKeys. true: query filter bypassed entirely for this context.
IDataOwnerDbContext.AccessibleKeys your DbContext you supply it Empty collection ⇒ deny all IOwnedBy rows (not “allow all”).
IDataOwnerProvider.GetAccessibleKeys() your provider wraps GetOwnershipKey() into one key, or [] Override for multi-key callers.
IDataOwnerProvider.GetOwnershipKey() your provider required, no default Owner key stamped on new IOwnedBy entities, and — unless an ICurrentUserProvider supplied a user for that save — on CreatedBy/UpdatedBy too; blank/null ⇒ hook skips stamping.
ICurrentUserProvider.GetCurrentUser() your provider, registered via AddCurrentUserProvider<TDbContext, TProvider>() in DKNet.EfCore.AuditLogs not registered Optional. A non-empty value for a save moves CreatedBy/UpdatedBy to that user and leaves this hook stamping OwnedBy only. Never affects the query filter or the reassignment guard.
DataOwnerAuthQuery.FilterKey fixed nameof(DataOwnerAuthQuery) Named EF Core 10 query filter key; used internally, not configurable.
DataOwnerAuthQuery.IsIgnorable fixed false Cannot be bypassed via ISpecification.IsIgnoreQueryFilters.

🧱 Where it fits

Ownership is enforced twice, by two different pieces: a global query filter on the read path and a before-save hook on the write path. Both read their keys from code you supply:

Architecture diagram of row ownership: your IDataOwnerProvider is usually the source of the keys your DbContext exposes as IDataOwnerDbContext; DataOwnerAuthQuery turns those keys plus the bypass flag into a never-bypassable global query filter over every IOwnedBy query, while DataOwnerHook takes the ownership key straight from the provider to stamp OwnedBy and guard reassignment before each save. On the write path an optional ICurrentUserProvider decides the audit identity: when it returns a user, EfCoreAuditHook stamps CreatedBy and UpdatedBy from that value and DataOwnerHook stamps OwnedBy alone; when it returns nothing, or is not registered at all, DataOwnerHook stamps the audit fields from the ownership key as before.

This package is a consumer of two other EF Core building blocks, not a standalone interceptor:

⚠️ Gotchas & limits