Policy Attributes
The compile-time half of the feature. Every field-level attribute below is sealed by default — no runtime rule can lift it unless you write Overridable = true. See Precedence.
Type level
| Attribute | Effect |
|---|---|
[DwEntity(RequirePolicy = true)] | Querying this type without a policy context throws PolicyRequired instead of returning rows. |
[DwEntity(DefaultOrder = "CreatedAt desc, Id")] | The order a guarded query takes when its caller sends none. See Default order. |
ApplyPolicy returns everything, and nothing complains. With it, the omission is a startup- loud failure on the first call rather than a silent disclosure.Only this library's own extension methods run the check, so plain EF Core or LINQ against the DbSet is not intercepted and returns rows as it always did.Default order
A caller who pages a query without ordering it gets whichever rows the database returns first, so two pages can repeat or miss a row. DefaultOrder names the order a guarded query takes when its caller sends none — Orders null or empty.
[DwEntity(RequirePolicy = true, DefaultOrder = "CreatedAt desc, Id")]
public class Ticket
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public string Title { get; set; } = string.Empty;
}- Entries are separated by commas. Each is a field path, optionally followed by
ascordescin any letter case, and ascending when neither is written. A path may cross a navigation, and a blank entry — the one a trailing comma leaves — is ignored. - It applies only through
ApplyPolicy: toToList,ToListAsync,ToListDynamicandToListAsyncDynamicwith aFilter, toToListAsyncwith aSegment, to the composableFilterandFilterDynamic, and to the composablePageon a source nothing has ordered and whose projection hides no field the default names. - It never applies outside the guarded handle. A core method on a plain
IQueryable<T>orIEnumerable<T>— including one called on whatAsUnguardedQueryable()returns — does not read it, and orders only as its caller asks, as in 3.0.0. - The caller's own orders win, and the default is not appended to them as a tiebreak. A query that is already ordered keeps its order, whether an
IQueryable<T>was ordered before it was guarded —db.Tickets.OrderBy(t => t.Title).ApplyPolicy(caller)— or anOrderwas composed on the guarded handle first, as inguarded.Order(order).Page(page)— even when the policy dropped every order that call sent. Since 3.2.0 aFiltercomposed on the handle with orders counts the same way. An in-memory sequence sorted beforeApplyPolicyis not recognised as ordered, because it reaches the policy as a query with noOrderByin it, so it takes the default; send that order with the filter instead. ASummarynever takes the default, and neither do the composableWhere,SelectandOrder. - A projection made before
ApplyPolicytakes the default only when it cannot hide a field the default names. Only the outermostSelectof the chain counts, because it makes the rows the default orders. Since 3.2.0 it hides nothing when it buildsTitself in an object initializer and assigns every field the default names a column, at every level of a nested path:"Owner.Name"needsOwner = new OwnerRow { Name = … }. On EF Core a column is a member the model maps on the entity theSelectreads, read directly (t.Code), through reference navigations (t.Owner.Name) or throughEF.Property, a shadow property included; in memory any assigned field is one. EF Core then translates the order. A projection over an anonymous or other intermediate row reads no entity, so it takes no default. - Any other projection leaves the query in its own order, as every projection did in 3.1.0: a constructor with arguments, a default field the initializer does not assign, a nested path through anything but an initializer, a member the model does not map, or a default field the projection computes, by the application's own method (
Label = Decorate(r.Code)), a framework one such asRegex.ReplaceorToUpper, or an operator. EF Core evaluates some of these on the client, where it can project the value but cannot order by it, and which ones it translates depends on the provider, so none is ordered by: a default must never be the reason a query that ran unguarded fails. - A projection composed on the guarded handle takes no default, even when it keeps every field the default names. The guarded
Select, as inguarded.Select(fields).Page(page), and a guardedFilterwhoseSelectsis set leave the rest of the chain unordered. The default is for the rows the caller's source makes.
[DwEntity(RequirePolicy = true, DefaultOrder = "CreatedAt desc, Id")]
public class TicketRow
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public string Title { get; set; } = string.Empty;
}
// Takes the default: the initializer builds TicketRow and assigns CreatedAt and Id.
var ordered = db.Tickets
.Select(t => new TicketRow { Id = t.Id, CreatedAt = t.CreatedAt, Title = t.Title })
.ApplyPolicy(caller);
// Keeps its own order: CreatedAt is not assigned, so the default could name
// a member these rows do not carry.
var unordered = db.Tickets
.Select(t => new TicketRow { Id = t.Id, Title = t.Title })
.ApplyPolicy(caller);The default is gated like any order. A field in it that this caller may not order by is left out — never refused, because the caller did not send it — and the trace records a Dropped decision for Order whose reason starts left out of the default order. Ordering by that field would rank rows by a value the caller may not see. In a Segment a field this caller may not use in a segment is left out too, and recorded the same way, because a segment refuses that field in any clause; a filter still orders by it. A dry run keeps the field and still records the decision, and a caller whose own orders were all dropped under the Convenience tier gets no default in their place.
A field the default keeps that is audited for Order, by [DwAudit(PolicyFeature.Order)] or by a rule, is recorded as a use, with Effect Allow, each time a guarded query orders by it, as a caller's own order is. A field the default leaves out is not recorded: the query does not order by it, and the caller never named it. A dry run keeps the field, so it records it with its Order effect — Deny for a field this caller may not order by — and DryRun true.
A default is never a reason for the library to refuse a query. An entry naming a field the type does not have is skipped, so is an entry that is not a field and a direction, so is one no query can order by, such as a collection of entities, and so is one starting with a name the expression parser keeps for itself; a field named twice is ordered by once. Nothing is ordered that the declaration does not name, so a type without a DefaultOrder is ordered only as its caller asks. Startup validation reports an unreadable entry, a field no query can order by, a field whose name starts with one of the expression parser's own words — which no query can reach at all — and a field the type's own attributes seal against ordering as errors. A field the type does not have, a field only Overridable attributes deny for ordering — a rule can lift those — and a field denied for segments are warnings.
"CreatedAt desc, Id" — so that no two rows tie.[DwEntity] allows one per type, and .NET attribute inheritance hands a derived type its own when it declares one. The base type's DefaultOrder and RequirePolicy are then gone, not merged: a subclass declaring [DwEntity(DefaultOrder = "Id")] no longer requires a policy. Repeat both on the derived type.Access control
| Attribute | Effect |
|---|---|
[DwDeny(features)] | The composable primitive. Refuse any combination of the six features. |
[DwDenied] | Refuse all six. |
[DwNoWhere] | Refuse filtering. |
[DwNoSelect] | Refuse projection. The field stays filterable and countable. |
[DwNoOrder] | Refuse sorting. The fix startup validation names for a masked field. |
[DwNoGroup] | Refuse grouping. |
[DwNoAggregate] | Refuse aggregation. |
[DwOperators(Allow = ..., Deny = ...)] | Restrict which operators may target the field. Restrictions from several sources intersect. |
// Confirmable, not searchable: a caller can check a code it already knows
// and cannot sweep for one it does not.
[DwOperators(Allow = new[] { Operator.Equal, Operator.In })]
public string EmployeeCode { get; set; }Selects would return the whole row. So a field denied for Select — at the top of the type, or beneath a member where its value can reach the result — makes a guarded query project the allowed members instead. See A request that sends no Selects.new, and on the implementation a type gives an interface member, explicit, inherited or declared by an open generic class, through a variant instantiation too. A row read through the base type or the interface is still that subtype, so the denial holds for the path on every row, in every clause. Until 3.2.0 only the declaration walked, and the attributes above it, were read. The other attributes are still read from the declaration walked only.Injection
| Attribute | Effect |
|---|---|
[DwAlias("name")] | A public name, accepted anywhere a field path is. Renamed back on the way out, after materialization. |
[DwForceWhere(op, Value =, ContextValue =, AllowNull =)] | A predicate ANDed into every guarded query, whether the caller asked or not. With AllowNull = true a row whose member is null passes as well — see below. |
[DwRequireWhere(Operators =)] | The caller must filter on this field. Throws in both tiers. |
// The row-level boundary. ContextValue reads from DwPolicyContext.Values,
// so the tenant comes from the request rather than from the source.
[DwForceWhere(Operator.Equal, ContextValue = "TenantId")]
public int TenantId { get; set; }
// Not a denial: an unscoped read of every department is the query worth
// refusing, and a requirement refuses it without blocking the scoped one.
[DwRequireWhere]
public string Department { get; set; }A forced predicate that lets null through
A record can belong to one tenant or to none — a system role no institution owns. An equality scope never matches the row whose column is null, and several forced predicates on one member are joined by And, so no combination of them can say "or null". AllowNull = true widens one predicate to (field op value OR field IS NULL).
// Tenant 5 sees its own roles, and the roles no institution owns.
[DwForceWhere(Operator.Equal, ContextValue = "TenantId", AllowNull = true)]
public int? InstitutionId { get; set; }- The widened term is placed in a group of its own and joined by
Andto the caller's group and to every other forced predicate, so a caller'sOrcannot merge with it:(Name = A OR Name = B) AND (InstitutionId = 5 OR InstitutionId IS NULL). - It works with every operator that takes a value. It is refused with
ArgumentExceptionat resolution, and reported by startup validation, onOperator.IsNullorOperator.IsNotNull, which already decide about null, and on a member that can never be null, such as anint. - The context value is still required. A context that does not supply
TenantIdis refused withMissingContextValue: the flag widens which rows pass, not which callers are scoped. - The widened term is a disjunction, so it does not satisfy a
[DwRequireWhere]on the same member. The caller still has to filter on it. - The trace records the injection as
forced predicate (Equal, or null), with the operator's name. A dry run injects nothing, as for every forced predicate.
A runtime rule sets the same flag on its ForcedPredicate — see Dynamic store.
Transformation
Covered in full on Transforms & masking.
| Attribute | Effect |
|---|---|
[DwMask(strategy)] | Obscure the value. Nine strategies. |
[DwMutate(typeof(T))] | Hand the value to your own IValueTransformer. |
[DwDefault] / [DwDefault("v")] | Replace with the type default or a constant. |
[DwGeneralize(mode)] | Reduce precision, keeping the type. |
[DwTruncate(n)] | Shorten text. |
[DwFormat("fmt")] | Render through a .NET format string. |
All six also carry AllowAggregate and MinGroupSize — see Security, because those two are the k-anonymity control and are easy to miss.
Discovery, cost and audit
| Attribute | Effect |
|---|---|
[DwDescribe(Label =, Description =, Group =, Order =)] | Describes the field for the schema endpoint, so a front end builds its filter UI from the entity rather than a hand-maintained copy. |
[DwAllowedValues(...)] | Offer a list rather than a free-text box. |
[DwCost(weight)] | Charge the field against the query budget, so an expensive field costs more of a caller allowance. |
[DwAudit(features)] | Record every use to IDwAuditSink. Refused queries are recorded separately, whether or not a field carries this attribute, by DwPolicyOptions.AuditRefusals. |
Overridable
Every field-level policy attribute carries Overridable, which defaults to false. One attribute can be sealed while another on the same member is replaceable.
Two exceptions. The type-level [DwEntity] derives from Attribute rather than the policy base and has no Overridable at all. And the flag decides nothing on [DwOperators] or [DwForceWhere], because those two are intersected and collected rather than elected — see Precedence.
// The mask is absolute; the description is a suggestion an operator may change.
[DwMask(MaskStrategy.Full)]
[DwDescribe(Label = "National ID", Overridable = true)]
public string NationalId { get; set; }[DwAlias], [DwRequireWhere] and [DwForceWhere] describe the entity being queried, so they are not replicated onto reflections of a type reached from itself — Employee.Manager, Category.Parent. A scope declared one navigation away on a different type, such as Order.Buyer.TenantId, still applies.