DynamicWhere.ex
DynamicWhere.exv3.0.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. Seven such channels are closed; 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.

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.

6 and 7. The two that are not channels

AttackControl
An unguarded call on a type that requires a policy[DwEntity(RequirePolicy = true)] throws rather than returning rows
An empty policy storeAttributes still enforce; an empty store never resolves to Allow

Getting the posture right

  • Use DwTier.Strict unless you need getQueryString.
  • 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 missed guard fails loudly.
  • Prefer [DwOperators] over allowing free filtering on a protected field.