DynamicWhere.ex
DynamicWhere.exv3.2.0·docs

Security & k-anonymity

Denying a field is easy. The hard part is the set of ways a caller can learn a value without reading it. Six such channels follow, then two bypasses that are not channels, then the requests that, until 3.2.0, carried out a denied value the gate could not see; each has a test that reproduces the attack and goes red if the control is removed.

MinGroupSize ships on, at 5
Read this page before you turn it off. The compatibility argument for shipping it off does not hold: the floor applies only to a guarded summary, and guarded queries are new in this release, so there is no caller anywhere whose results it can change.

1. Set operations reconstruct a denied field

Segment composes UNION, INTERSECT and EXCEPT. Where a field is deny-select but allow-where:

AllEmployees EXCEPT (AllEmployees WHERE Salary > 100000)

returns exactly the people earning under 100k, by name, with the salary column never selected. The protected value is reconstructed from set membership.

Closed by: policy applies to every segment independently, and in the Strict tier a deny-select field is automatically deny-where inside a Segment.

2. Aggregates over singleton groups

SUM, MAX and MIN execute in SQL against the real values, before any transform can apply. GROUP BY Department with MAX(Salary) over a department of one returns that person exact salary.

Closed by two halves, and neither works alone:

  1. Aggregating a transformed field is denied by default — all six transform attributes, not masks alone — and opted into with AllowAggregate = true.
  2. MinGroupSize suppresses any group smaller than k. Groups below the floor are removed from the result.
[DwGeneralize(GeneralizeMode.Round, Step = 5000,
              AllowAggregate = true, MinGroupSize = 5)]
public decimal Salary { get; set; }
The floor is on by default, and switching it off is one line
DwCaps.MinGroupSize defaults to 5. Writing MinGroupSize = 1 switches it off and it is off — in production, with nothing refused and nothing warned about. A deployment that wants singleton groups is entitled to them.

The setting starts unset rather than at one, which is what makes both halves possible: IsMinGroupSizeSet tells a deliberate opt-out from a deployment that never heard of the control. Without that distinction, any check strict enough to catch the second would trap the first. A per-field MinGroupSize on any transform attribute raises the floor for that field; the effective floor is the largest in play.

new DwPolicyOptions()                                 // floor of 5
new DwPolicyOptions { Caps = { MinGroupSize = 1 } }   // no floor, and meant
new DwPolicyOptions { Caps = { MinGroupSize = 10 } }  // stricter

The floor suppresses rows; it does not refuse the query. A summary whose every group is a singleton returns nothing.

3. TotalCount cardinality disclosure

ToList computes Count() on the pre-pagination query. Filtering Salary > 200000 and reading TotalCount counts the high earners without selecting anything.

This is inherent to permitting WHERE on a protected field. The control is [DwOperators] restricting the field to Equal and In, so a caller can confirm a value it already knows and cannot sweep for one it does not. A documented consequence, not a defect.

4. Sort plus paging is a binary search

Sorting by a masked field ranks the real values. Paging through a known set reveals relative magnitude, and combined with range filters it converges on exact values.

Closed by: startup validation warns when a field is transformed but still orderable, and [DwNoOrder] is the explicit fix. A warning rather than an error because there are models where the ordering is the point and the transform is cosmetic — the engine names the fix rather than deciding for you. A declared default order cannot reopen the channel: a field in [DwEntity(DefaultOrder = ...)] that the caller may not order by is left out of their query, and recorded in the trace.

Employee.Email: the value is transformed on output but the field can still be
sorted on, and sorting runs against the real value. Paging through it ranks the
true order. Add [DwNoOrder] unless that is intended.

5. getQueryString leaks the generated SQL

Returning raw SQL exposes injected tenant predicates and the column names of denied fields. The Strict tier throws QueryStringDenied; the Convenience tier allows it, documented.

The trace a result carries names the same things — the fields a policy dropped, what sealed each one, and every injected predicate — and an API that serializes a result hands it over. The Strict tier therefore keeps it off the result unless IncludeTraceInResult is true; it stays on PolicyQueryable<T>.LastTrace, in-process.

6. A refusal tells a missing field from a denied one

A caller who may not read a column can still ask about it. When a name that matches nothing fails validation while a denied field is refused by the policy — naming the field and the attribute that sealed it — every guess is answered: this column does not exist, that one does and is hidden. Repeated, the probe lists the schema, the columns the caller may never read included.

Closed by: under the Strict tier, outside a dry run, a name that matches nothing is gated as a field denied for every feature, at the step where a denial is raised and after the same caps a real field passes, so it receives the code a [DwDenied] field receives in that clause — FieldDeniedForWhere … FieldDeniedForSegment. All six codes carry FieldPath "*" and no RuleId or SourceOrigin, and CapExceeded names no path either, so the two refusals are identical. The trace keeps the real path, and AuditRefusals writes every refused guess to the audit — a guess at a name that does not exist included, which no [DwAudit] could record. The Convenience tier still names the field, documented. See What a strict refusal says.

The strict tier closes the side doors too. Inside a Segment every field refusal is FieldDeniedForSegment, so a field denied for every clause but not for segments cannot answer by clause while a missing name answers for taking part. A name padded with dots or blank segments is normalized the way a real path is, so it cannot trip the navigation cap that a padded real field passes. MaxQueryCost is checked only after every field has passed its gate, so a field weighted by [DwCost] is refused as denied before its weight could set it apart from a name that does not exist. And MissingContextValue names neither the scope's column nor the context key it reads, which together describe how the rows are partitioned.

7 and 8. The two that are not channels

AttackControl
An unguarded DynamicWhere call on a type that requires a policy[DwEntity(RequirePolicy = true)] throws PolicyRequired rather than returning rows. Only this library's own extension methods run the check, so plain EF Core or LINQ against the DbSet is not intercepted — the flag closes the hole in this API, not every route to the table.
An empty policy storeAttributes still enforce; an empty store never resolves to Allow

Denials the gate could not see

A denied field often sits on a type the query reaches through a member: a secret on each line of an order, a code inside a nested object. The denial holds on every path that reaches it, and it has to hold whether or not the caller names the member. Until 3.2.0 each request below carried a denied value out. All are closed.

AttackControl
Send no Selects, on a type whose only denied fields sit beneath a memberA guarded query synthesizes a projection whenever the denied value can reach the result, and narrows the member around it or leaves the member out. Only a simple field denied at the top of T used to synthesize one, so the whole row came back with the denied value in it: in a list or nested object of a row projected before ApplyPolicy, in a row held in memory, and in an entity's included, automatically included, lazily loaded or owned member — in both tiers. A denial beneath a navigation nothing loads never leaves the database, so it asks for nothing. See A request that sends no Selects.
Send no Selects, on a type whose denied field holds no simple value: a blob, a list, an owned object, a JSON columnA field denied at the top of T asks for the projection whatever it holds. Such a field used to be passed over, so with nothing else denied the whole row came back with it.
Name a navigation whose key, Id, is deniedRefused with FieldDeniedForSelect in both tiers. The core's typed projection adds the key of every nested node it builds, so the convenience tier used to narrow the key away and get it back. A navigation named through another, Main.Lead, now gates the key of Main as well, which the projection adds.
Name a member typed IReadOnlyList<T>, or another collection the core does not unwrap, with a denied field beneath itRefused with FieldDeniedForSelect in both tiers. The projection gate now reads collections the way the attribute walker does. It used to read them through a narrower list, found nothing beneath such a member, and returned every field, the denied ones included, in both tiers.
Name a member that carries a denied field no path reaches: deeper than four segments, inside a framework generic such as Dictionary<string, T>, or, on an entity's navigation, in its owned chain or a converted columnRefused under Strict. Under Convenience it is narrowed where the core can narrow it and refused where it cannot. What the member carries is read from the source — from the EF Core model for an entity, so only what loads counts. The gate also reads the rules themselves, so a denied property with no setter and a rule on a path reached through a cycle are found beneath a named member too.
Include a navigation from the root, then reach the rows through it — Select(o => o.Customer), SelectMany, Join — or hide a projection behind another SelectEvery navigation counts as loaded on such a chain, since EF Core still applies includes named from the root to the entities it reaches, and the library cannot read which. The includes used to be read against the wrong root, so the denied value beneath them was returned.
Let a lazy loader fill a navigation after the query: a loader delegate or ILazyLoader the constructor takes, kept in a field or a property of any nameCounts as loading every navigation, as EF Core's proxies and an injected ILazyLoader property already did. The model keeps no record of such a loader, so the navigation it filled came back with the denied value.
Declare the denied field on a subtype — a derived entity, a subclass, an interface's implementation — and read it through the base type: a query over the hierarchy's root, or a member declared as the base typeThe subtypes are read too: the types the EF Core model derives for an entity, and for a projected or in-memory row every loaded subtype, an open generic one and an application's subclass of a framework class such as Exception included. Such rows are projected to T and such members narrowed to the declared type; a named one is refused under Strict. A projection constructing a subtype of T is read as it, and a rule on a subtype's field through a base-typed member is enforced. The policy used to read the declared type only.
Put the [DwDenied] on an override, on a public member a subtype hides with new, or on a class's implementation of an interface member, and read the member through the base type or the interface, a variant instantiation of it includedThe denial applies to the path for every row, in every clause. The attribute walker read the declaration it walked and the attributes above it, never an override, a hiding member or an implementation below, so the base path filtered, sorted, grouped and returned the value.
Guard a query through a provider that wraps EF Core's, as LinqKit's AsExpandable or DelegateDecompiler's Decompile doThe query runs untracked. EF Core's AsNoTracking hands such a query back unchanged, so it tracked: the context filled in navigations it already held, the denied ones included, and a masked value became a pending change the next SaveChanges would write. The call now goes into the query itself.
Under a "*" deny with exact allows, reach a path the walk never asks about: past four segments, around a cycle, a property with no setterSuch a path is denied, so a member holding one is narrowed, left out or refused. It used to resolve as allowed, so the member was returned whole, named or not.
Put the policed type in an application namespace that starts with System, such as SystemsCorp.PayrollPoliced. The walker read any namespace starting with System as the framework's and put no policy beneath its types, so a [DwDenied] field there was returned, filterable and sortable. Only System and the namespaces beneath it are the framework's now.
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, a synthesized projection over a projected row or rows in memory leaves it out, and naming it returns whatever it holds. 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 member EF Core does not map is read as its type, since its getter can hand out what EF Core loaded; a getter that copies a denied column into a type with no denial is the application's to withhold.
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.

Getting the posture right

  • Use DwTier.Strict unless you need getQueryString.
  • Leave IncludeTraceInResult unset under Strict. A serialized result carries the trace to the caller; read it from LastTrace instead.
  • Turn on AuditRefusals once an IDwAuditSink is registered, so a probe for hidden columns leaves a record.
  • Leave MinGroupSize alone unless you have a reason; setting it to 1 is a decision, not a default.
  • Prefer Tokenize over Hash where you can run a durable vault: neither hides equality, but only one of them can be undone by a leaked constant.
  • Run DwPolicy.ValidateModel(...) at startup and treat its warnings as a checklist.
  • Put [DwEntity(RequirePolicy = true)] on anything sensitive, so a DynamicWhere call that forgets ApplyPolicy fails loudly.
  • Prefer [DwOperators] over allowing free filtering on a protected field.
  • Hold a policed type in a list, never in a dictionary, another framework generic or a member typed object. The policy has no paths into any of them.
  • Scope a list's elements where the row is built. A forced scope on the element type filters the rows that hold the list, never the elements.
  • Set DwCaps.DefaultPageSize if the API does not page for itself. It ships off, and the request MaxPageSize never bounded is the one that sent no page at all.
  • Keep DwCaps.MaxConditionSets near the number of sets your clients really send. A set with no conditions passes every other cap, and every set adds a condition or a subquery to the statement a segment becomes.