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.
ApplyPolicy(ctx). Add [DwEntity(RequirePolicy = true)] to the class so a DynamicWhere call that forgets throws instead of quietly returning everything. It guards this library's extension methods and nothing else: plain EF Core or LINQ against the DbSet still reads the table.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 thisWhat 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.
TenantId raises MissingContextValue in both tiers. A tenant scope that silently fails to apply is worse than a failed request, so neither tier skips it. A dry run injects no forced predicate at all, so there the missing value is recorded in the trace and nothing is thrown.Rows that belong to no tenant. A role no institution owns has a null scope column, and InstitutionId = 5 never matches null, so the scope hides that role from every caller. Two forced predicates on one member are joined by And and cannot say "or null"; AllowNull can:
public class Role
{
[DwForceWhere(Operator.Equal, ContextValue = "TenantId", AllowNull = true)]
public int? InstitutionId { get; set; }
}What the caller gets. Their own institution's roles and the roles that belong to none. The widened term sits in a group of its own, so (A OR B) AND (InstitutionId = 5 OR InstitutionId IS NULL) still cannot reach institution 9. A context with no TenantId is still refused with MissingContextValue, and a [DwRequireWhere] on the same member is not satisfied by the widened term. See the attribute.
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 sendsStartsWith "4242"and readsTotalCountto 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.
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 moreWhy 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;
}The scope is what makes the join a decision rather than a coincidence. Left off, the namespace falls back to the field's path relative to the entity being queried, with no type name in it — so these two members, spelled identically, already share tokens, and would stop sharing them the day somebody renamed one or moved it behind a navigation. Write the scope and the join survives both.
It costs something either way, which is why it is worth writing down: a caller who can see both columns learns the two rows concern the same person. Two columns that should not give each other away need distinct scopes for exactly the same reason — the default will not separate them for you when the paths happen to match.
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 rule for the finance role now outranks that mask, and POST /dw-policies/explain shows which level decided, what it overrode and what tied with it. What such a rule cannot do is hand back the raw value: each transform stage is elected among the fragments that carry that stage, so a rule with no mask of its own leaves the attribute's mask the only candidate. A rule replaces the mask with its own, or decides Select; it never removes one.
Showing one role the value itself is a transformer, not a rule — it is the stage that can read the caller:
public sealed class AccountNumberForFinance : IValueTransformer
{
public object? Transform(object? value, DwTransformContext context)
{
if (context.Policy.Identities(DwSubjectKind.Role).Contains("finance"))
{
return value;
}
string raw = value as string ?? string.Empty;
return raw.Length <= 4
? raw
: new string('*', raw.Length - 4) + raw.Substring(raw.Length - 4);
}
}
[DwMutate(typeof(AccountNumberForFinance))]
public string AccountNumber { get; set; } = string.Empty;A rule targeting RecoveryKey is rejected at write time, when the store can resolve the entity type, and loses at resolution time in every case — [DwDenied] is sealed, so no dynamic level outranks 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.
[DwAudit] records the uses of the fields it decorates, so a probe aimed at other fields — denied ones, or names that do not exist — leaves nothing in that log. Turn on DwPolicyOptions.AuditRefusals and every refused guarded query is written to the same sink, carrying its ErrorCode.
The pairs, in one table
If you remember nothing else from this page, remember that the left column alone is not a control.
| This | needs this | or else |
|---|---|---|
[DwMask] | [DwNoOrder] | sorting ranks the real values and paging reads them off |
AllowAggregate = true | a floor left in place | a group of one returns the exact value under any aggregate — which needs the global DwCaps.MinGroupSize (5 by default) to have been switched off (set to 1), with no per-field floor above 1 — the larger of the two applies |
| a mask on a filterable field | [DwOperators] | TotalCount counts matches without selecting anything |
MaskStrategy.Hash | HashSalt, 16+ chars | the query is refused with MissingHashSalt |
MaskStrategy.Tokenize | TokenVault | the query is refused with MissingTokenVault |
| a token joined across entities | a shared TokenScope | the namespace falls back to each field's own path, so the join works only where the paths happen to match, and breaks on a rename |
| any policy at all | [DwEntity(RequirePolicy)] | a path that forgets ApplyPolicy returns everything |
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.