Field-Level Policies
DynamicWhere.ex accepts a Filter from any caller and turns it into an EF Core query. The caller chooses which fields to filter on, sort by, select, group by and aggregate. Policies add the question the library could not previously answer: who is asking, and what are they allowed to see?
DwPolicy.Configure call behaves exactly as 2.1.5. There is no break in the 2.x API — see Breaking Changes.A sandwich, not a rewrite
Requests are sanitized before the query is built. Results are transformed after they materialize. The query engine in between is unchanged, which is why turning policies on cannot alter the SQL of an unguarded query.
caller request
|
v
query.ApplyPolicy(ctx) the only entry point; returns a guarded handle
|
v
sanitize drop or refuse denied fields, restrict operators,
enforce caps, inject aliases and forced predicates
|
v
existing DynamicWhere untouched
|
v
transform mask, mutate, default, generalize, truncate, format
|
v
PolicyTrace what the policy did: always on LastTrace, and on
result.Policy as IncludeTraceInResult decides
(off under the strict tier by default)The whole thing in one screen
// Once, at startup. A second call is refused: the tier is read by every
// request thread, and a posture that can change mid-flight can be relaxed.
DwPolicy.Configure(new DwPolicyOptions
{
Tier = DwTier.Convenience,
HashSalt = secret,
});
// Once per request, never once per query. A context that skipped this is
// refused: ApplyPolicy throws PolicyContextNotPrepared, store or no store.
var caller = await DwPolicy.PrepareAsync(
new DwPolicyContext()
.WithSubject(DwSubjectKind.User, userId)
.WithSubject(DwSubjectKind.Role, "Support")
.WithSubject(DwSubjectKind.Tenant, tenantId)
.WithValue("TenantId", tenantId));
// Then query through the guarded handle instead of the raw IQueryable.
var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter);[DwEntity(RequirePolicy = true)] // an unguarded DynamicWhere read throws
public class Employee
{
[DwMask(MaskStrategy.Email), DwNoOrder]
public string Email { get; set; } // s*************@c******.com on the way out
[DwForceWhere(Operator.Equal, Value = "true")]
public bool IsActive { get; set; } // ANDed into every guarded query
[DwGeneralize(GeneralizeMode.Round, Step = 5000,
AllowAggregate = true, MinGroupSize = 5)]
[DwNoOrder, DwAudit, DwCost(10)]
public decimal Salary { get; set; } // rounded; aggregatable over groups of 5+
[DwDenied]
public JsonDocument? WorkSchedule { get; set; }
}The guarded handle
ApplyPolicy returns a PolicyQueryable<T>. Its terminals mirror the core's: ToList, ToListAsync, ToListDynamic and ToListAsyncDynamic with a Filter, ToList and ToListAsync with a Summary, and ToListAsync with a Segment. Each one sanitizes the request, runs it, and transforms the rows.
Since 3.2.0 every asynchronous terminal on the handle also has overloads that take a CancellationToken, as the core's do. The policy is applied first, so a refusal is thrown whatever the token says. The token then reaches the count and the read. The overloads sit beside the 3.1 signatures, which are unchanged.
// PolicyQueryable<T>: new in 3.2.0, beside the overloads without a token
Task<FilterResult<T>> ToListAsync(Filter filter, CancellationToken cancellationToken)
Task<FilterResult<T>> ToListAsync(Filter filter, bool getQueryString, CancellationToken cancellationToken)
Task<FilterResult<dynamic>> ToListAsyncDynamic(Filter filter, CancellationToken cancellationToken)
Task<FilterResult<dynamic>> ToListAsyncDynamic(Filter filter, bool getQueryString, CancellationToken cancellationToken)
Task<SummaryResult> ToListAsync(Summary summary, CancellationToken cancellationToken)
Task<SummaryResult> ToListAsync(Summary summary, bool getQueryString, CancellationToken cancellationToken)
Task<SegmentResult<T>> ToListAsync(Segment segment, CancellationToken cancellationToken)// In a minimal API, a CancellationToken parameter is the request's own:
// a client that disconnects cancels the count or the read.
var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancellationToken);default fits both bool getQueryString and CancellationToken, on the handle as on the core, so the call is ambiguous. The same goes for ToListAsyncDynamic(filter, default) and ToListAsync(summary, default). Write false, a token, or a named argument.Six features, per field
Every decision is made for one field and one feature: Where, Select, Order, Group, Aggregate, Segment.
Segment is its own flag rather than a combination of Where and Select, because set operations can reconstruct a hidden field from membership alone — see Security.
Where to go next
- Attributes — all twenty-two, with what each one does
- Precedence — six levels, and why attributes are sealed by default
- Transforms & masking — nine mask strategies and five other transforms
- Dynamic store — rules without a redeploy
- Store providers — Redis and Entity Framework Core
- Admin API — schema, rules, explain, simulate, health
- Security & k-anonymity — the eight inference channels
- Configuration — options, caps and defaults
The four packages
| Package | What it adds |
|---|---|
DynamicWhere.ex | Attributes, resolver, sanitizer, transforms, in-memory store, discovery, audit |
DynamicWhere.ex.Policies.Redis | Runtime rules in Redis |
DynamicWhere.ex.Policies.EntityFrameworkCore | Runtime rules in any EF Core provider |
DynamicWhere.ex.Policies.AspNetCore | Admin API, claims adapter, audit middleware |
All four ship at the same version, and the build refuses to let them drift apart.