DynamicWhere.ex
DynamicWhere.exv3.2.0·docs

Configuration

DwPolicy.Configure(new DwPolicyOptions
{
    Tier                 = DwTier.Convenience,
    DryRun               = false,
    IncludeTraceInResult = null,               // null follows the tier: on here, off under Strict
    AuditRefusals        = false,              // true also audits every refused guarded query
    HashSalt             = secret,             // 16 characters or more
    TokenVault           = tokenVault,         // needed only by MaskStrategy.Tokenize
    Services             = serviceProvider,    // resolves IValueTransformer
    StoreFailure         = StoreFailureMode.LastKnownGood,
    MaxSnapshotAge       = TimeSpan.FromMinutes(15),
    RefreshInterval      = TimeSpan.FromSeconds(30),
}, providers);
Frozen at startup, and refused on a second call
The posture is read by every request thread without synchronization. A tier that can change while requests are in flight is one that can be relaxed by a code path nobody expected to be security-relevant, so mutation after Configure throws.

AttributePolicyProvider is added whether or not you pass it. Attributes are the sealed level, and a configuration that omitted them would let a store grant what the source refuses.

Tiers

TierA denied fieldAlso
Convenience (default)Is dropped from the projection or sortgetQueryString allowed; the trace is on the result; a refusal names the field
StrictThrowsgetQueryString throws; deny-select implies deny-where inside a Segment; the trace stays off the result; a field that does not exist is refused like a denied one, and no field refusal names the field

A dropped field leaves nothing behind in the data, so FilterResult<T>.Policy is the only way a caller can tell a policy drop from a null value. That is why the convenience tier, which drops, puts the trace on the result unless IncludeTraceInResult is false.

A Selects entry can name a navigation, such as "Lines", rather than the fields beneath it. With nothing denied beneath it, the entry is kept as written. With a denied field beneath it, the Convenience tier replaces the entry with the allowed fields beneath it, and the Strict tier refuses it.

Selects names a navigationConvenienceStrict
with nothing denied beneath itKept whole, or narrowed around a transform that lands on a property with no setter (3.2.0)Kept whole, or narrowed the same way
with a denied field beneath itReplaced by the allowed fields beneath itFieldDeniedForSelect
whose key, Lines.Id, is denied (3.2.0)FieldDeniedForSelectFieldDeniedForSelect
with a denied field beneath it, where the narrowing cannot be built (3.2.0)FieldDeniedForSelectFieldDeniedForSelect
that can carry a field denied for Select that no path names: past four segments, in a framework generic, on a subtype, or unasked under a "*" deny (3.2.0)Narrowed to the allowed fields where the core can narrow it; FieldDeniedForSelect where it cannotFieldDeniedForSelect
  • The fields beneath a member are read the way the attribute walker reads them: through any collection type, and no deeper than its four segments. Since 3.2.0 a member typed IReadOnlyList<T>, IReadOnlyCollection<T>, Collection<T> or an application's own collection no longer hides the denials beneath it. The providers' own rules are asked too, so a denied property with no setter and a rule on a path reached through a cycle count.
  • A narrowing that cannot be built as it was gated is refused in both tiers (3.2.0). The core's typed projection adds the key, Id, of every nested node it builds, so a navigation narrowed around its own denied key would get the key back. The core reads a path only through an array, List<T>, IList<T>, ICollection<T>, IEnumerable<T>, HashSet<T> or ISet<T>, so a narrowing through any other collection fails its validation. And some members cannot be narrowed at all: a column, a complex property or a member stored as JSON, which EF Core reads whole; a member of a row in memory; and a member a projection builds some way the core cannot narrow.
  • A named member can also carry a field denied for Select that no path names (3.2.0): one deeper than four segments, one inside a framework generic such as Dictionary<string, T>, or one a subtype of the member's type declares — a derived entity, a subclass, an interface's implementation. What it can carry is read from the source. On an entity it is read from the EF Core model, so only what loads counts: the navigation's columns, a converted one included, its owned chain at any depth, the navigations beneath it an include, an automatic include or a lazy loader fills, and each member the model does not map, read as its type, since its getter can hand out what EF Core loaded — for its type and every type the model derives from it. On a projected row it is the type the initializer constructs the member as, when it says, and otherwise the member's type and every loaded subtype of it, as on a row in memory. Under a policy with a "*" deny, a path the walk never asks about — past four segments, with no setter, or on a subtype — is a denied one unless the policy names it; around a cycle it always is. The Strict tier refuses such a member. The Convenience tier narrows it where the core can, which builds the declared type and so drops a subtype's fields; a path naming a framework generic itself narrows to nothing and is dropped. Where the core cannot narrow it — a column at the top of T, a member of a row in memory — both tiers refuse it.
  • A navigation named through another, Main.Lead, gates the key of every node it passes through, which the core's projection adds, as a dotted path to a value always did (3.2.0). A denied key refuses the projection. Naming a field beside a denied key, Lines.Name when Lines.Id is denied, was already refused in both tiers.
  • Under Convenience the refusal names the denied key, the first denied field beneath the member, or, for a denial no path names, the member itself. Under Strict its FieldPath is "*", as on every field refusal. The trace records the reason either way.

A request that sends no Selects

A request that sends no Selects returns whole rows, denied fields included, because the core projects only when Selects is set. So when a field denied for Select could reach the result, a guarded query synthesizes the projection itself. It does so in both tiers, typed and dynamic, for a whole Filter and for a Segment. A clause composed on its own, such as Where, Order or Page, synthesizes nothing.

The projection keeps the allowed members: what an unguarded call would return, less what the policy withholds. Before 3.2.0 it kept the allowed scalars only, and only a simple field denied at the top of the type asked for it — see breaking changes.

When a projection is needed

  • A field denied at the top of T always asks for one, whatever it holds: a scalar, a blob, a list, an owned object or a JSON column.
  • A field denied beneath a member asks for one when its value can reach the result. On an entity, that is beneath a column, an owned or complex member, or a navigation something loads: an Include or ThenInclude on the query, an automatic include, or a lazy loader — EF Core's proxies, an injected ILazyLoader, a loader delegate or ILazyLoader the constructor takes and keeps in a field or any property, the asynchronous loader delegate of EF Core 7, or an injected DbContext — which fills a navigation after the query. On a row a projection builds, it is beneath a member the initializer assigns; a constructor with arguments counts every member as assigned, and an initializer after it still says what its own bindings hold. On a row in memory, it is beneath any member. A rule may spell the path in any letter case.
  • Every navigation counts as loaded where the library cannot read which the query loads: an Include in a form it cannot read, one off the query's own chain, and a chain that reaches its rows through anything but the root's own rows — Select(o => o.Customer), a SelectMany, a Join, a GroupBy — when it also has an include, which EF Core applies from the root to the entities it reaches, or when one of its lambdas hands its rows an object: one it builds, as a projection behind an identity Select, or an object built inside an anonymous row or a conditional, does; one an application's method returns from what the lambda gives it; or one it captured, another query with its own include or projection, or an object in memory. A call that reads nothing of the lambda's and returns a query or an expression (a specification, a repository's query, FromSql) is evaluated as EF Core evaluates it, and what it returns is read; a context's own query function is a query root; an anonymous object that only carries what the rows hold, range variables or a composite key, builds nothing; and what only feeds a predicate or a key is a value. Such a chain with none of these is read from the model.
  • A denial beneath a navigation nothing loads never leaves the database, so it asks for no projection. An entity whose only denials sit beneath such navigations is read as it was in 3.1.0.
  • A member whose value can hold a field denied for Select that no path names — deeper than the walker's four segments, inside a framework generic such as Dictionary<string, T>, or declared by a subtype of its type — asks for one too, read as for a named member, so on an entity only what loads counts. Under a "*" deny, so does a member whose value can hold a path the walk never asks about and the policy does not name.
  • A member that can hold an object of any type — one typed object, a framework interface such as IComparable, or a collection that is not generic, such as IEnumerable, ArrayList or an application's own — asks for nothing on its own: the policy cannot see into it whether or not a projection is built.
  • A row can be a subtype of T. On an entity, a member a type the model derives from T declares, and what loads beneath it, counts as one of T's own would; on a row in memory, a member any loaded subtype declares does; on a row a projection builds, a member the type its initializer constructs declares below T. The projection builds T and leaves them out, recorded with a reason starting left out: a type derived. A subtype is any type loaded outside the framework's assemblies that derives from the type or implements it, an open generic one and an application's subclass of Exception included; a rule on a path through a subtype's member counts as a rule on the declared type's own path does.
  • A member EF Core does not map counts as loaded: its getter can hand out a mapped field or a private navigation, so its type is read whole.
  • The denials beneath a member come from the providers' rules as well as from walking the type, so a denied property with no setter, a rule on a path reached through a cycle, and a rule deeper than the walk all count.
  • A forced scope beneath a member asks for no projection on its own. It filters the rows that hold the member, as it always has. When a projection is needed anyway, the member is left out whole.

What it keeps

A member holding a value — a simple type, or a collection of one such as byte[], string[] or List<string> — is kept when it is allowed and the source carries it. A member holding an object, or a list of them, is kept whole, narrowed or left out whole, as below, and only where the source carries it:

SourceValues keptObjects kept
A projection that builds its rows before ApplyPolicy: the outermost Select constructs the row, in an object initializer or with a constructor, as in db.Roles.Select(r => new RoleRow { … })Every member the initializer assigns; every member when a constructor with arguments builds the row, with or without an initializer after itThe same
An entity query, or a Select that hands back an entity, as in db.Orders.Select(o => o.Customer)Every member EF Core mapsIts columns, converted and JSON ones included, its owned members and, on EF Core 8 or later, its complex properties, read from the EF Core model; a converted value that can hold an object of any type is left out
Rows in memory, as in roles.ApplyPolicy(caller)Every memberNone

A value EF Core does not map is left out: computing it would make EF Core read the whole entity, the denied columns included, and it holds only its initial value anyway. A source the library cannot read — no EF Core model, and neither a projection it can see into nor rows in memory — keeps values only, as in 3.1.0, and every denial beneath a member counts.

Whole, narrowed or left out whole

A member holding an object that the source carries is:

  • kept whole when nothing beneath it is denied, nothing its value can hold is denied (its subtypes included), it cannot hold an object of any type (asked of a projected row, a row in memory, and an entity's column a value converter hands back, directly or inside a complex property: what EF Core materializes itself never holds one), under a "*" deny every path beneath it the walk skips is one the policy names, no forced scope is beneath it, and no transform beneath it lands on a property with no setter;
  • narrowed otherwise, to the allowed fields beneath it, four segments deep, as a caller naming it would get it, where the core's narrowing translates: an object the projection's initializer builds, a list a subquery reads into a type the core can bind (not an array or a set), a navigation that is neither complex nor stored as JSON, or an entity's owned member not stored as JSON. The narrowing builds the declared type, so a subtype's fields are dropped. A field beneath it that can hold what the policy cannot name is left out;
  • left out whole otherwise, and recorded as Dropped on Select with a reason that starts left out whole.
left out whole: a scope forced beneath it cannot be applied to what it holds
left out whole: it is a column, which EF Core reads whole
left out whole: it is a complex property, which EF Core cannot narrow
left out whole: it is stored as JSON, which EF Core cannot narrow
left out whole: the projection builds it in a way the core cannot narrow
left out whole: the projection would add its key 'Contents.Id', which is denied
left out whole: the core cannot project 'Contents.Code'
left out whole: the core cannot build 'IContact', which it narrows into
left out whole: nothing beneath it may be selected
left out whole: it can hold what the policy cannot name

The last one is recorded on the field beneath the member that the narrowing leaves out.

Never kept

  • An entity's navigation, included or not: projecting it would load it. So once a denial needs a projection, an included or automatically included navigation is not returned, and the trace records it as Dropped with a reason starting left out:. Under Convenience, name it in Selects to get it narrowed; under Strict, name its allowed fields.
  • An object held by a row in memory: a kept object is the caller's own, and a transform beneath it would change it in place. The projection's rows are new and hold no member of an object type, so the source objects are left as they were.
  • A member with no setter, and a member named with one of the expression parser's own words.

Other rules

  • A narrowed reference that is null in the source comes back as an empty object, as it does for a caller's own dotted Selects.
  • A narrowed member carries every allowed field beneath it. An entity reached beneath it therefore has its own navigations projected, and so loaded, whether or not the source included them, exactly as when Selects names the member.
  • Each denied field whose value can reach the result, at the top or beneath, is recorded as Dropped on Select.
  • It never throws for a denied field, in either tier. It throws AllSelectsDenied only when no field is left. When nothing asks for a projection, Selects stays null and the query is the one an unguarded call runs.
  • A typed query projects into T, so T needs a public parameterless constructor, or the query fails with SelectTypeMustHaveParameterlessConstructor. The dynamic terminals do not need one.
  • A dry run synthesizes nothing. It records the denials and returns the rows whole.
  • A simulation has no source, so it reads T as a source it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value — see Simulate.
What the policy cannot see into
A member typed object, a framework interface or a collection that is not generic, such as IEnumerable, ArrayList or an application's own, is opaque to the policy. It never asks for a projection; when one is needed anyway, a projected row, a row in memory, and an entity's converted column leave it out, and an entity's other columns keep it; and naming it returns whatever it holds. A converter returning an application type through a column typed object is opaque the same way, so type the member as what it holds. BitArray and the framework's string collections hold values. An application's own collection class, generic or not, still has its own members read, and a collection of values stays a value unless one of them is denied. Two members sharing a name are left out when either holds a denial. A framework generic holding a policed type, such as Dictionary<string, LineDto>, has no paths beneath it: naming it is refused in both tiers where the core cannot narrow it, narrowed away under Convenience beneath a navigation, and a synthesized projection leaves it out. Hold such values in a list of the policed type instead.
A projection builds the declared type
A query over the root of a hierarchy whose derived type declares a denied field comes back as root-type rows, the derived types' allowed fields dropped too. Over an abstract root the typed terminals fail with SelectTypeMustHaveParameterlessConstructor, and the dynamic ones return the root's members. Query the derived type, OfType<Company>(), to keep its fields. Rows in memory can be any loaded subtype, and the policy does not look at the rows: when a subtype declares a denied field, they are projected and their objects left out, even if no row is that subtype. Under a "*" deny, a member is kept whole only when every path beneath it the walk skips is one the policy names.
A forced scope on a list's element type filters rows, not elements
A forced scope declared on a list's element type filters the rows that hold the list, never its elements. Selects naming the list returns every element, those the scope excludes included, as in every release; a synthesized projection leaves such a list out. Scope the elements where the row is built.

The trace on a result

Every guarded query records a PolicyTrace: what the policy dropped, refused, transformed or injected, and why. IncludeTraceInResult (bool?, default null) decides whether the terminals also put it on the result — FilterResult<T>.Policy, SummaryResult.Policy and SegmentResult<T>.Policy.

IncludeTraceInResultConvenienceStrict
null (default)On the resultPolicy is null
trueOn the resultOn the result
falsePolicy is nullPolicy is null

The strict tier keeps it off because a result is where it reaches the caller. An API that serializes a result serializes the trace with it, and the trace names the fields a policy dropped, the attribute or rule that sealed each one, and every predicate injected on the caller's behalf — the detail that tier already refuses to return through getQueryString. The trace is recorded either way, on PolicyQueryable<T>.LastTrace, and audit events do not depend on the setting. It freezes with the posture and binds from the key IncludeTraceInResult. Before 3.1.0 every guarded result carried the trace, whatever the tier — see breaking changes.

// A strict deployment whose results never leave the server can keep the trace on them.
DwPolicy.Configure(new DwPolicyOptions
{
    Tier                 = DwTier.Strict,
    IncludeTraceInResult = true,
}, providers);

// Whatever the setting, the handle that ran a query holds what the policy did.
var guarded = db.Employees.ApplyPolicy(caller);
var result  = await guarded.ToListAsync(filter);
PolicyTrace? trace = guarded.LastTrace;

What a strict refusal says

A refusal that names the field it refused tells a probing caller that the field exists. Under the Strict tier, outside a dry run, a refusal says no more than which clause was refused:

  • A field path that names nothing on the type does not fail validation. It is gated as a field denied for every feature, at the step where a denial is raised — after the caps — so it gets the code a [DwDenied] field gets in that clause: FieldDeniedForWhere, FieldDeniedForSelect, FieldDeniedForOrder, FieldDeniedForGroup or FieldDeniedForAggregate. A name padded with dots or blank segments, such as NoSuchColumn...., is normalized the way a real path is, so it is refused as a padded real field is.
  • Inside a segment every field refusal is FieldDeniedForSegment, with Feature Segment, whichever clause refused it — a condition in any set, an order, a select, or the field taking part at all. A field denied for every clause but not for segments would otherwise answer by clause where a name that matches nothing answers for taking part. Filters and summaries keep their per-clause codes.
  • Every refusal with one of those six codes has FieldPath "*", a null RuleId and a null SourceOrigin, whether the field was denied, named through an alias, or does not exist, so the message is identical as well: FieldDeniedForOrder: field '*', feature 'Order', tier 'Strict'.
  • A CapExceeded refusal has FieldPath "*" too, and its SourceOrigin still names the cap.
  • MissingContextValue has FieldPath "*" and a null SourceOrigin. Together the scope's column and the context key it reads describe how rows are partitioned, so the message names neither.
  • MaxQueryCost is checked after every field has passed its gate. A field weighted by [DwCost] that the caller may not use is refused as denied before its weight counts, as a name that matches nothing is, so the budget cannot tell them apart. An allowed weighted field still gets QueryCostExceeded.
  • The trace keeps the real path and the reason: an unknown name is recorded as Denied, with the reason names nothing on followed by the type's name. The trace also keeps a missing context value's column and key. With AuditRefusals on, the audit event keeps the real field as well.

The Convenience tier answers as it always did: an unknown field fails validation with LogicException ConditionMustHasValidFieldName, a refusal names the field as the caller wrote it, with RuleId and SourceOrigin where one source decided, and MaxQueryCost is checked before any field is gated. In a dry run, which refuses nothing, an unknown name fails validation in either tier. See Security.

Caps

CapDefaultMeaning
MaxPageSize1000Largest page a caller may request.
DefaultPageSize0Page given to a guarded query that asked for none. Zero, the default, leaves it unpaged.
MaxConditions50Conditions in one filter.
MaxConditionDepth10How deep a filter may nest its condition groups, counting the root group as one.
MaxConditionSets10Condition sets in one segment. Each set adds to the one statement a segment becomes.
MaxConditionValues1000Values in any one condition, such as the list of an In.
MaxAggregates50Aggregates one summary computes.
MaxOrderFields10Order fields in one query.
MaxNavigationDepth4How deep a field path may reach.
MaxQueryCost1000Budget consumed by [DwCost] weights.
DefaultFieldCost1Charged for an unweighted field, and for an aggregate with no field.
MaxAuditEvents10000Audit buffer before draining.
SchemaDepth2Levels a schema request walks when it names no depth.
SchemaCycleLimit2Times one type may appear on one path.
MaxSchemaFields2000Fields one schema response may carry before it truncates.
MinGroupSize5k-anonymity group floor. Set 1 to switch it off. See Security.

Every cap is frozen at startup, and a cap that refuses records it in the trace; DefaultPageSize, which refuses nothing, records nothing. Most refuse a value below one. Two accept 0 and refuse only a negative value: DefaultFieldCost, which is the posture for a model that weighs its few expensive fields and wants the rest free, and DefaultPageSize, where zero is how the page-filling stays switched off.

They do not all refuse alike, and the error code says which fired. MaxPageSize, MaxConditions, MaxConditionDepth, MaxConditionSets, MaxConditionValues, MaxAggregates, MaxOrderFields, MaxNavigationDepth and MaxAuditEvents share CapExceeded, naming the cap in SourceOrigin. Under the Convenience tier MaxNavigationDepth and MaxAuditEvents also put the field's path on FieldPath; under Strict every CapExceeded has FieldPath "*". MaxQueryCost is the one with a code of its own, QueryCostExceeded, because an operator reading a log needs to know which of the two refused: raising the wrong one changes nothing. The three schema caps never throw at all — a depth is clamped, a cycle is pruned, and MaxSchemaFields truncates and reports truncated — and MinGroupSize suppresses groups rather than refusing the query. DefaultPageSize refuses nothing either: it supplies a page rather than rejecting a request that carried none.

DefaultPageSize is the other half of MaxPageSize, which only ever read a page the caller sent — so the one request no cap applied to was the request with no page at all. It returned the whole table, while the same request naming that page size was refused with CapExceeded. Set it, and a guarded query that sent no page is given PageNumber = 1 and a size of DefaultPageSize bounded by MaxPageSize, so the two cannot be configured into contradicting each other. A page the caller did send is never replaced, and is still refused when it is too large. It ships off because filling one in on upgrade would truncate an existing caller's results with nothing in the response to say so. A page filled in for a caller who sent no orders is only as stable as the query's order, so pair it with a [DwEntity(DefaultOrder = ...)] that ends with a unique field.

It applies to a Filter, Summary or Segment on the composable methods as well as the terminal ones, so the composable Filter, FilterDynamic and Summary hand back a query that is already paged: page through the request's Page, not a Page() chained after it. Where, Order, Select and Group take no page and are never given one.

A Segment is one statement: its condition sets are combined, ordered and paged in the database, so for a segment as for a filter DefaultPageSize and MaxPageSize bound what is read as well as what is returned.

MaxConditionDepth bounds the shape MaxConditions says nothing about: fifty conditions in one flat group and fifty nested fifty deep both pass the count, and only the second makes the provider plan fifty parenthesised groups. The root group is depth one, so the default of ten allows nine levels of nesting under it — deeper than any filter a person writes and shallower than anything generated by accident. It refuses in both tiers like every other cap, and it is measured on a Filter's condition group, on the deeper of a Summary's conditions and its Having, and on each Segment condition set separately — a segment sums its conditions across every set, but its depth is the depth of one.

MaxConditionSets bounds how large that statement can get. Every set adds to it — a condition for a Union or an Intersect, a NOT EXISTS subquery for an Except — and a set with no conditions spends nothing from MaxConditions or MaxConditionDepth, so the number of sets is the only bound on it. It counts every set the caller sent, empty or not, and refuses in both tiers with CapExceeded.

MaxConditionValues bounds what one condition carries. An In or a NotIn is one comparison per value, so a single condition could hand the database a predicate of any size while spending one condition from MaxConditions and one field from MaxQueryCost. The condition carrying the most values is the one compared, wherever it sits: a filter's conditions, a summary's conditions and its Having, every set of a segment.

MaxAggregates bounds the AggregateBy entries of one summary, through the summary terminals and the composable Group and Summary. Every aggregate is a column of every group, and one with no field — a Count — names nothing a weight could be set on, so it is also charged DefaultFieldCost toward MaxQueryCost. The count the group-size floor adds for itself is neither counted nor charged.

The caps that count — MaxConditions, MaxConditionDepth, MaxConditionSets, MaxConditionValues, MaxAggregates, MaxOrderFields and MaxPageSize — are checked before any field name is resolved, so an oversized request is refused before its names are looked at, a name that does not exist included. MaxNavigationDepth needs a resolved path and runs after them.

MaxConditionDepth, MaxConditionSets, MaxConditionValues and MaxAggregates are new in 3.1.0, so a guarded request 3.0.0 ran — a filter nested eleven groups deep, a segment with eleven sets, a summary with fifty-one aggregates — is refused unless the deployment raises the cap. See breaking changes, for the first two and the last two.

MinGroupSize is the one that starts unset rather than at its default value, so that MinGroupSize = 1 can mean "no floor, and I mean it" rather than being indistinguishable from a deployment that never configured anything. IsMinGroupSizeSet reports which of the two happened.

Dry run

DryRun traces every decision without enforcing any of them, so a policy can be rolled out and watched before it starts refusing anything. It is also per-context, not only global, so a single canary role can run in dry run while everyone else is enforced — an all-or-nothing rollout is the thing nobody does.

var canary = new DwPolicyContext { DryRun = true }
    .WithSubject(DwSubjectKind.User, userId);

Auditing refusals

[DwAudit] records the uses of the fields it decorates. A caller probing for columns they may not read is refused at every guess, and a guess at a field without [DwAudit], or at a name that does not exist, leaves nothing in that log. With AuditRefusals = true (default false), every PolicyException raised by a guarded entry point — each terminal and composable method of PolicyQueryable<T>, and ApplyPolicy's refusal of an unprepared context — is written to the caller's DwPolicyContext audit buffer, PendingAuditEvents. It drains to IDwAuditSink like any [DwAudit] event, through DwPolicy.DrainAuditAsync or the ASP.NET Core audit middleware. The setting freezes with the posture and binds from the key AuditRefusals.

DwAuditEvent memberOn a refusal
ErrorCodeThe refusal's PolicyErrorCode. New in 3.1.0, and null on an event that records a use of an audited field.
EntityTypeThe full name of the type being queried.
FieldPathThe field the refusal was about, by its canonical path in both tiers: the path an alias stands for, and under the Strict tier the real field although the refusal the caller received said "*". A name that matches nothing is recorded as the caller sent it. A refusal of the whole request, such as QueryStringDenied or PolicyContextNotPrepared, records "*"; MissingContextValue names the scoped field.
FeatureThe feature the refusal concerned.
EffectDeny.
Subjects, Purpose, TierThe caller and the posture, as on every event.
DryRunfalse: the refusal was enforced. A dry run refuses no field, so it records no field refusal; a refusal it still raises, such as PolicyContextNotPrepared, is recorded with DryRun false.
  • A refusal is written at most once, and it is never changed or swallowed: the caller receives the same exception whether or not it was recorded.
  • A recorded path is cut to 256 characters, followed by …. After the cut, every character in Unicode category Control (Cc), Format (Cf), Line Separator (Zl) or Paragraph Separator (Zp) is written as \u and four lowercase hex digits: a line feed as \u000a, U+2028 as \u2028, U+202E as \u202e. A character outside the Basic Multilingual Plane is judged whole, and both halves of its surrogate pair are escaped. An unknown name is text the caller wrote. A line break in it would forge a second entry in a log written one event per line, and a format character such as U+202E would reverse the text after it without showing itself.
  • A buffer already holding MaxAuditEvents events records nothing, and the original refusal is still the one thrown.
  • A refusal with no guarded context to record against is not written. PolicyRequired, raised by an unguarded read of a RequirePolicy type, is one.
  • DwAuditEvent.ToString() includes the code when there is one. The constructor gains an overload that takes it as a tenth argument, PolicyErrorCode? errorCode; the nine-argument constructor is unchanged.
public sealed class LogAuditSink : IDwAuditSink
{
    private readonly ILogger<LogAuditSink> _log;

    public LogAuditSink(ILogger<LogAuditSink> log) => _log = log;

    public ValueTask WriteAsync(DwAuditEvent auditEvent, CancellationToken ct = default)
    {
        if (auditEvent.ErrorCode is PolicyErrorCode refused)
        {
            _log.LogWarning("{Code} on {Entity}.{Field}", refused, auditEvent.EntityType, auditEvent.FieldPath);
        }
        else
        {
            _log.LogInformation("{Feature} on {Entity}.{Field}", auditEvent.Feature, auditEvent.EntityType, auditEvent.FieldPath);
        }

        return default;
    }
}
Off by default, because it changes what reaches a sink
A deployment that registered a sink for [DwAudit] starts receiving events that carry an ErrorCode, and one that registered none is warned about discarded events on every refused request by the audit middleware.

Startup validation

// Throws an InvalidOperationException listing every error, so reaching the
// next line already means the model is sound. Do not test report.Errors here:
// it is always empty by the time you can read it.
PolicyModelReport report = DwPolicy.ValidateModel(typeof(Employee), typeof(Customer));

foreach (var warning in report.Warnings) logger.LogWarning("{W}", warning);

PolicyModelValidator.Inspect is the same check without the throw, for a health endpoint or a report that wants to list the errors rather than fail on the first one.

PolicyModelReport inspected =
    PolicyModelValidator.Inspect(new[] { typeof(Employee), typeof(Customer) });

foreach (var error in inspected.Errors) logger.LogError("{E}", error);

Reported as a list rather than thrown one at a time, so a model is fixed in one pass instead of one exception per restart. Errors cover contradictions that cannot work — a text-emitting transform on a numeric member, a [DwMutate] type that is not an IValueTransformer, a default that cannot be read as the member type. Warnings cover things that work but probably should not, chiefly a transformed field that is still orderable.

Every [DwForceWhere] is checked the way resolution checks it, so a malformed one is reported at startup rather than on the first guarded query of its type: one that sets neither or both of Value and ContextValue, a null check that sets either, a member whose type has no DataType, and AllowNull = true on IsNull or IsNotNull or on a member that can never be null. Before 3.1.0 these surfaced only when a query ran.

A [DwEntity(DefaultOrder = ...)] is read entry by entry. A default order is never a reason to refuse a query, so this scan is the only place a mistake in one is reported. For [DwEntity(DefaultOrder = "Missing desc, Watchers, Null, Secret, Rank, Region, Id sideways")] on a Ticket whose Watchers is a collection of entities, whose Null is a member named after one of the expression parser's own words, whose Secret carries [DwNoOrder], whose Rank carries [DwNoOrder(Overridable = true)] and whose Region carries [DwDeny(PolicyFeature.Segment)]:

Ticket: DefaultOrder entry 'Id sideways' is not a field optionally followed by asc or desc, so guarded queries skip it.
Ticket: DefaultOrder names 'Missing', which Ticket does not have, so guarded queries skip it.
Ticket: DefaultOrder names 'Watchers', which no query can order by, so guarded queries skip it.
Ticket: DefaultOrder names 'Null', which starts with a name the expression parser keeps for itself, so no query can use it. Rename the member.
Ticket: DefaultOrder names 'Secret', which its attributes deny for ordering, so every guarded query leaves it out.
Ticket: DefaultOrder names 'Rank', which its attributes deny for ordering unless a rule allows it, so guarded queries leave it out until one does.
Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so guarded segments leave it out.

The first, third, fourth and fifth are errors: an entry that cannot be read meant something, a collection of entities holds no single value to sort by, a field whose first segment is one of the parser's own words — new, iif, np, isnull, is, as, cast, true, false, null, in any letter case — is one no query can reach at all, and a field the type's own attributes seal against ordering is left out of every guarded query, so the declared order is never the one used. The rest are warnings. A model shared across types can name a field on purpose; a denial every attribute marks Overridable can be lifted by a rule for the callers it names, so Rank is left out only until one does; and Region is left out only of guarded segments, which refuse it in any clause, while a filter still orders by it.

Configuration from a file

Every value on the posture binds from IConfiguration. Three things cannot, because they are objects rather than values: the entity catalogue, the token vault and the service provider. Those stay in code, which is what the callback is for.

builder.Services.AddDwPolicies(
    builder.Configuration.GetSection("DynamicWhere:Policies"),
    options =>
    {
        options.Entities.Expose<Employee>("Employee");
        options.TokenVault = new RedisTokenVault(redis);
    });
{
  "DynamicWhere": {
    "Policies": {
      "Tier": "Strict",
      "AuditRefusals": true,
      "StoreFailure": "LastKnownGood",
      "MaxSnapshotAge": "00:15:00",
      "Caps": {
        "DefaultPageSize": 100,
        "MinGroupSize": 5,
        "SchemaDepth": 2,
        "MaxSchemaFields": 2000
      }
    }
  }
}

Configuration binds first and the callback runs second, so a line somebody wrote deliberately is never overwritten by a file. Every key is optional, and an absent one leaves its default in place.

A key nothing answers to refuses to start
The binder's own default is to ignore an unmatched key, which would let MinGropSize sit in a file doing nothing while the deployment believed it had set a floor. Binding runs with ErrorOnUnknownConfiguration, so a typo fails at startup rather than silently switching a control off.

Every setter's own validation still applies. A cap below one, a snapshot age that is not a positive interval and a salt shorter than sixteen characters are all refused exactly as they are in code. The group floor's opt-out survives unchanged, because it lives in the setter: saying nothing leaves it unset, writing 1 records a deliberate choice.

A salt in appsettings.json is not a salt
HashSalt binds like anything else, and configuration is the right channel for it — through user secrets, an environment variable or a vault. Committing it to a file in the repository is the thing the attribute refuses to allow, and nothing here can tell the difference.

Performance

There are two budgets, because there are two costs. Gating is paid once per query. Transformation is paid per row per transformed field, so no single percentage describes it — the same guard is 1.16× over a hundred rows and 1.62× over ten thousand, on identical code.

Measured with BenchmarkDotNet over 10,000 in-memory rows:

10,000 rowsTimeAllocated
Unguarded685 µs210 KB
Guarded, nothing denied or transformed683 µs (1.00×)220 KB (1.05×)
Guarded, one field deny-select785 µs (1.15×)409 KB (1.95×)
Guarded, two fields transformed every row1,111 µs (1.62×)1,488 KB (7.1×)

Gating costs nothing measurable. Resolving every field, sanitizing the filter and injecting forced predicates lands inside the noise of the unguarded query. A cached field resolve is 232–234 ns and sanitizing a five-condition filter is 2.8 µs.

Deny-select costs 1.15×, because denying a field means the query projects instead of returning entities. That belongs to the feature rather than to the guard.

Transformation costs about 21 ns and 65 bytes per value, against a design budget of 100 ns. It builds a new value for each one, because the change happens after materialization rather than in SQL.

No database in those numbers
These are in-memory LINQ, so the policy layer share looks as large as it ever can. Against a real query the I/O dominates and the relative overhead is much smaller.
dotnet run -c Release --project DynamicWhere.Benchmarks \
  -- --filter "*PolicyBenchmarks*" --job medium

Error codes

PolicyException.ErrorCode, values 1 to 22:

CodeRaised when
FieldDeniedForWhere … FieldDeniedForSegment (1–6)A field is refused for that feature. Where, Group, Aggregate and Segment throw in both tiers, because dropping one of those would widen the result set or answer a different question. Only Order and Select are tier-dependent: the Convenience tier drops the clause instead. Under Strict a field path that names nothing gets the same code, and all six carry FieldPath "*" with no RuleId or SourceOrigin; inside a segment every one of them is FieldDeniedForSegment — see What a strict refusal says.
AllSelectsDenied (7)Every requested field was denied.
OperatorNotAllowed (8)An operator outside the permitted set.
CapExceeded (9)A cap above was exceeded; SourceOrigin names it. Under Strict, FieldPath is "*".
PolicyRequired (10)An unguarded query on a RequirePolicy type.
RequiredFilterMissing (11)A [DwRequireWhere] field was not filtered on.
MissingContextValue (12)A forced predicate needed a context value that was absent. Under Strict, FieldPath is "*" and SourceOrigin is null.
AmbiguousFieldName (13)A name could mean more than one field.
QueryStringDenied (14)getQueryString in the Strict tier.
AmbiguousGroupKey (15)Two groups of a summary share a key once their key values were transformed, so their aggregates cannot be added together without inventing a figure.
TransformRequiresMaterialization (16)A transform on a query the caller materializes itself.
StoreUnavailable (17)The store failed under FailClosed, or the context's pinned snapshot is older than MaxSnapshotAge.
PolicyContextNotPrepared (18)ApplyPolicy was handed a context that never went through PrepareAsync — refused whether or not a store is configured — or a store saw one it had attached nothing to, or the caller gained a User subject after preparation.
QueryCostExceeded (19)The query cost budget was exceeded. Under Strict it is checked after every field gate.
GroupTooSmall (20)A summary already uses the alias the group floor reserves.
MissingHashSalt (21)A field masks to a hash and no salt was configured.
MissingTokenVault (22)A field masks to a token and no vault was configured.