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.
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:
- Aggregating a transformed field is denied by default — all six transform attributes, not masks alone — and opted into with
AllowAggregate = true. MinGroupSizesuppresses 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; }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 } } // stricterThe 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
| Attack | Control |
|---|---|
| An unguarded call on a type that requires a policy | [DwEntity(RequirePolicy = true)] throws rather than returning rows |
| An empty policy store | Attributes still enforce; an empty store never resolves to Allow |
Getting the posture right
- Use
DwTier.Strictunless you needgetQueryString. - Leave
MinGroupSizealone unless you have a reason; setting it to 1 is a decision, not a default. - Prefer
TokenizeoverHashwhere 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.