DynamicWhere.ex
DynamicWhere.exv3.0.0·docs

Use cases

The attribute reference lists the parts. This page shows the machine. Almost every real control here is a pair of attributes, and the pairing is the part you cannot infer from either half.

Each recipe below states the requirement, gives the code, says what the caller actually receives, and then names what leaks if you drop one attribute. That last line is the point of the page.

Every recipe assumes the guard is on
A policy only applies to a query that went through ApplyPolicy(ctx). Add [DwEntity(RequirePolicy = true)] to the class so a code path that forgets throws instead of quietly returning everything.

1. A tenant must never see another tenant's rows

The row-level boundary. The caller does not ask for it and cannot widen it: a forced predicate is collected and ANDed rather than elected, so even a rule with higher authority can only narrow it further.

[DwEntity(RequirePolicy = true)]
public class Invoice
{
    [DwForceWhere(Operator.Equal, ContextValue = "TenantId")]
    public Guid TenantId { get; set; }

    // The soft-delete half of the same idea: a constant rather than a context value.
    [DwForceWhere(Operator.Equal, Value = "false")]
    public bool IsVoid { get; set; }
}
var caller = await DwPolicy.PrepareAsync(
    new DwPolicyContext()
        .WithSubject(DwSubjectKind.Tenant, tenantId)
        .WithValue("TenantId", tenantId));   // the ContextValue above reads this

What the caller gets. Their own rows, whatever filter they sent. The injected predicate wraps their condition group rather than merging into it, so (A OR B) AND TenantId = 5 — never (A OR B OR TenantId = 5), which would return every tenant.

Drop the ContextValue and the query fails, deliberately
A context that does not supply TenantId raises MissingContextValue in both tiers. A tenant scope that silently fails to apply is worse than a failed request, so there is no mode in which it is skipped.

2. A support agent may find a customer but not read their card

The agent needs to confirm an identifier a caller reads out to them over the phone. They must not be able to sweep for one, and must not see the stored value at all.

[DwOperators(Allow = new[] { Operator.Equal, Operator.In })]
[DwMask(MaskStrategy.Partial, KeepEnd = 4)]
[DwNoOrder]
public string CardNumber { get; set; } = string.Empty;

What the caller gets. ************4242, and a query for an exact number they already know still matches, because filtering runs against the stored value in SQL.

Three attributes, three different jobs. Take any one away and a different hole opens:

  • Without [DwOperators] the agent sends StartsWith "4242" and reads TotalCount to count matches — the cardinality channel, and the mask never comes into it.
  • Without [DwMask] the number is simply returned.
  • Without [DwNoOrder] the agent sorts by the column and pages through it. Sorting ranks the real values, so the order alone reveals magnitude, and combined with range filters it converges.

3. An analyst needs salary bands, never salaries

The k-anonymity recipe, and the one control on this page a reader cannot guess from the API.

[DwGeneralize(GeneralizeMode.Round, Step = 5000,
              AllowAggregate = true, MinGroupSize = 5)]
[DwNoOrder]
public decimal Salary { get; set; }

What the caller gets. Salaries rounded to the nearest 5,000, and grouped reports where every group holds at least five people. Groups below the floor are removed from the result and recorded in the trace; the query is not refused.

Neither half works alone

SUM, MAX and MIN run in SQL against the stored value, before any transform applies. So MAX(Salary) over a department of one returns that person's exact pay, and the rounding never happened.

That is why aggregation over a transformed field is denied by default. AllowAggregate = true opens it, and MinGroupSize is what makes the opening safe. Setting the first without the second turns a control into a hole.

The global floor is DwCaps.MinGroupSize, which defaults to 5. A per-field MinGroupSize raises it for that field; the effective floor is the largest in play. See Security.

4. A warehouse must join on an identifier it may never read

Two export pipelines, run separately, whose output has to line up on the same person. Nobody downstream may recover the identifier.

[DwMask(MaskStrategy.Hash)]
[DwNoOrder]
public string EmployeeCode { get; set; } = string.Empty;
new DwPolicyOptions { HashSalt = config["Dw:HashSalt"] }   // 16 characters or more

Why a hash and not a token here. The two pipelines never talk to each other. Both hold the same salt, so both compute the same digest for the same person with no shared table, no lookup and no ordering dependency between the jobs. A vault cannot do that, because agreeing would mean sharing the vault.

The trade you accept: whoever holds that salt holds every digest the deployment has ever emitted, and a card number or a national identifier has few enough possible values to recompute in seconds. Use a hash for identifiers that are already high-entropy, and read the next recipe for the ones that are not.

5. A regulated identifier, and a subject who can ask to be erased

National identifiers, medical record numbers, card numbers. Low-entropy, long-lived, and covered by a right to erasure.

[DwMask(MaskStrategy.Tokenize)]
[DwNoOrder]
public string NationalId { get; set; } = string.Empty;
new DwPolicyOptions { TokenVault = new EfTokenVault(() => new DwPolicyDbContext(opts)) }

Why a token and not a hash. Two reasons, and the second is the one that decides it.

  • A token is drawn at random rather than computed, so there is no secret whose leak makes every past value recoverable offline.
  • Erasure actually works. Delete the subject's row and a hash still sits in every export and backup, recomputable by anyone with the salt. Delete the vault mapping and the token becomes a meaningless random string permanently.

The library ships no reverse lookup on purpose, so erasure is a direct operation against the store. The key is public so you can compute it:

string key = DwToken.KeyFor("patient-id", nationalId);

// EF Core: DELETE FROM DwPolicyTokens WHERE [Key] = @key
// Redis:   HDEL dw:policy:tokens <key>

6. One person, three entities, one token

A claim, a visit and an invoice all carry the same patient identifier, and a report has to join them without anyone reading it.

public class Claim
{
    [DwMask(MaskStrategy.Tokenize, TokenScope = "patient-id")]
    [DwNoOrder]
    public string PatientNationalId { get; set; } = string.Empty;
}

public class Visit
{
    [DwMask(MaskStrategy.Tokenize, TokenScope = "patient-id")]
    [DwNoOrder]
    public string PatientNationalId { get; set; } = string.Empty;
}

Leave the scope off and the join silently returns nothing. Tokens are namespaced by the field's own path by default, so the same identifier produces a different token on each entity. That default is the safe one: two columns holding one value should not give each other away unless somebody decided they should.

Naming a shared scope is that decision written down, and it costs something — a caller who can see both columns learns the two rows concern the same person.

7. Most roles see a mask, one role sees the value

Attributes are sealed by default: no runtime rule can lift one. Opting a field into runtime lifting is a single flag, and it is the only way an operator can ever be granted more than the source code allows.

// Masked for everyone, unless a rule with higher precedence says otherwise.
[DwMask(MaskStrategy.Partial, KeepEnd = 4, Overridable = true)]
public string AccountNumber { get; set; } = string.Empty;

// Nothing at runtime can grant this. Not to an admin, not by a rule, not ever.
[DwDenied]
public string RecoveryKey { get; set; } = string.Empty;

A runtime rule for the finance role then raises it, and POST /dw-policies/explain shows which level decided, what it overrode and what it ignored. A rule targeting RecoveryKey is rejected at write time and again at resolution time — the operator cannot even attempt it.

8. The caller uses their own names for fields

An alias adds a spelling and never removes one, so decorating a field is not a breaking change to a filter already in production.

[DwAlias("customer_name")]
[DwDescribe(Label = "Customer", Group = "Identity", Order = 1)]
[DwAllowedValues("Active", "Suspended", "Closed")]
public string Name { get; set; } = string.Empty;

The caller may write either spelling going in, and the column comes back under the alias. [DwDescribe] and [DwAllowedValues] feed the schema endpoint, so a filter UI can render a labelled dropdown rather than a free-text box without knowing the model.

9. A field nobody should be able to query a thousand times

[DwCost(10)]
[DwAudit(PolicyFeature.Select | PolicyFeature.Where)]
public string FullTextNotes { get; set; } = string.Empty;

Every reference is charged, not every distinct field, because charging per field would let a caller generate the same work by naming one field a thousand times. [DwAudit] records each use to your IDwAuditSink, and reaching MaxAuditEvents refuses the query rather than dropping the record — an audited field whose log quietly stopped being written is the outcome the attribute exists to prevent.

The pairs, in one table

If you remember nothing else from this page, remember that the left column alone is not a control.

Thisneeds thisor else
[DwMask][DwNoOrder]sorting ranks the real values and paging reads them off
AllowAggregate = trueMinGroupSizea group of one returns the exact value under any aggregate
a mask on a filterable field[DwOperators]TotalCount counts matches without selecting anything
MaskStrategy.HashHashSalt, 16+ charsthe query is refused with MissingHashSalt
MaskStrategy.TokenizeTokenVaultthe query is refused with MissingTokenVault
a token joined across entitiesa shared TokenScopeeach field gets its own tokens and the join finds nothing
any policy at all[DwEntity(RequirePolicy)]a path that forgets ApplyPolicy returns everything
What no combination on this page closes
Both Hash and Tokenize map one value to one output, which is what keeps the column groupable and joinable. So anyone who can write a chosen value and read the column back learns that value's stand-in, and can recognise it in every other row. No setting removes this. If the caller has no reason to group or join on the column, use Fixed, Null, or deny it outright.