Summary#
A policy gives an authorization rule a name. Declare it once, then say the name wherever it applies — in an
entity's security { }, on a page, and inside a function as an ordinary boolean. A policy may take a parameter, which
is how you say "may they manage this one" rather than "are they a manager of something".
Signature#
policy Name => <predicate over `user`>;
policy Name(Type parameter) => <predicate over `user` and that parameter>;Description#
The predicate is about the caller. user is whoever is asking, and the rule may look at any data it needs to
decide — including rows the caller could not read themselves. That is deliberate: whether you may do a thing must
not depend on which rows you happen to be allowed to see, or every grant becomes circular.
[Principal] entity Person {
[Required, MaxLength(200), Unique] string Email;
security { allow read when IsAuthenticated; }
}
entity Team {
[Required, MaxLength(80)] string Name;
security { allow read when IsAuthenticated; }
}
entity TeamLead {
[Required] Person Person;
[Required] Team Team;
security { allow read when IsAuthenticated; }
}
// "is a lead of some team" — a fact about the caller alone.
policy IsALead => TeamLead.Any(l => l.Person == user);Call it like the boolean it is#
A policy reads as a boolean, so you can use it as one. This is the alternative to copying the predicate into every function that needs it — one rule in as many copies as your app has entry points, each able to drift, in the one category where drift is a hole rather than a bug.
string LeadsOnly() {
if (!IsALead) { throw new NotAuthorized("Only a team lead may do this."); }
return "done";
}A parameter is what makes it about this one#
IsALead asks whether you lead anything. Most real authority is narrower — may you manage this team? Give the
policy a parameter and pass the row:
policy CanManageTeam(Team t) => TeamLead.Any(l => l.Person == user && l.Team == t);
string Rename(Guid teamId, string name) {
var team = Team.Where(t => t.Id == teamId).FirstOrDefault();
if (!CanManageTeam(team)) { throw new NotAuthorized("Only a lead of THIS team may rename it."); }
team.Name = name;
return name;
}The difference matters more than it looks. A lead of another team passes IsALead and fails CanManageTeam(team) —
and that is usually the only case that tells a working rule from a broken one, because a manager and a non-member
behave the same under both.
The rule the engine enforces can be composed too#
A where may CALL a parameterised policy, passing the row it is about. This is where naming rules stops being tidy
and starts being the difference between a rule you can read and one nobody dares touch.
Take a grant-handling system. Whether a caseworker may read a case is four independent rules at once:
- they work for the unit that owns the scheme it was applied under;
- they have not recused themselves from this particular case;
- if the case is restricted, they are senior enough to see one;
- and they are signed in at all.
Written inline, that is one welded expression — and it has to be repeated on every entity that hangs off a case: the attachments, the assessments, the notes, the decision, the payment.
entity Case {
security {
allow read where RoleGrant.Any(g => g.Holder == user && g.Unit == Scheme.OwningUnit)
&& !Recusal.Any(r => r.Person == user && r.Case.Id == Id && r.Lifted == false)
&& (!Restricted || RoleGrant.Any(g => g.Holder == user && g.Level == Level.Senior));
}
}⚠ The problem is not that it is long — it is that the fifth copy is where a clause goes missing, and nothing catches that. It compiles, that entity's own tests pass, and the defect is "a caseworker read a case they had recused themselves from". Nobody sees it until an audit.
Name each rule once, and the predicate reads like the sentence it enforces:
[Role] enum Level { Caseworker, Senior }
[Principal] entity Person {
[Required, MaxLength(200), Unique] string Email;
security { allow read when IsAuthenticated; }
}
entity Unit { [Required, MaxLength(60)] string Name;
security { allow read when IsAuthenticated; } }
entity RoleGrant { [Required] Person Holder; [Required] Level Level; Unit? Unit;
security { allow read when IsAuthenticated; } }
entity Scheme { [Required, MaxLength(60)] string Name; [Required] Unit OwningUnit;
security { allow read when IsAuthenticated; } }
entity Recusal { [Required] Person Person; [Required] Case Case; bool Lifted;
security { allow read when IsAuthenticated; } }
// Each rule, named once. The first two are ABOUT a row, so they take one.
policy HandledByUnit(Unit u) => RoleGrant.Any(g => g.Holder == user && g.Unit == u);
policy HasRecused(Guid caseId) => Recusal.Any(r => r.Person == user && r.Case.Id == caseId && r.Lifted == false);
policy MaySeeRestricted => RoleGrant.Any(g => g.Holder == user && g.Level == Level.Senior);
entity Case {
[Required, MaxLength(40)] string Reference;
[Required] Scheme Scheme;
bool Restricted;
security {
allow read where HandledByUnit(Scheme.OwningUnit)
&& !HasRecused(Id)
&& (!Restricted || MaySeeRestricted);
}
}Nothing is given up for that. A policy INLINES — the persisted predicate is byte-for-byte the welded one above, so the database does the same work and a named rule costs nothing at run time. What changes is everything around it:
| welded | composed | |
|---|---|---|
| the recusal rule lives in | five places | one |
renaming Lifted | five edits, and a miss compiles | one edit |
| a missing clause on entity five | silent | still silent — but there is only one clause to miss |
| reading the rule | parse the expression | read the names |
And a query says nothing about it#
The rule lives on the entity, so a query over Case is just a query. It filters for what the caller is looking
for; who the caller is allowed to see is already inside the statement the compiler emits, composed from the four
rules above. Nothing here restates a grant, names a role, or checks anything:
List<Case> RestrictedUnder(string scheme) {
return Case.Where(c => c.Restricted && c.Scheme.Name == scheme)
.OrderBy(c => c.Reference)
.ToList();
}A caseworker outside the unit gets an empty list, not an error; a recused one does not see the case they recused from; a junior one sees no restricted case at all — and the function above is the same function for every one of them. That is the point of putting the rule on the entity: there is no call site that could have forgotten it.
⚠ Pass the ID, not the row. HasRecused(Id) — a row predicate has no this, and the compiler will tell you so
(that is a fact about predicates, not about policies: the welded form cannot say this either).
when or where — the argument decides#
A rule has two clauses: when asks who the caller is, once per request, with no row in hand; where narrows
rows, one at a time. A parameterised policy called with one of this entity's own members can only be about the
row — IsAdmin(Organization) written on RoleGrant means this row's Organization, and nothing else a C# reader
could take it for — so it is a row rule whichever word you wrote. Both of these compile, and they compile to the
same rule:
[Role] enum OrgRole { Admin, Member }
[Principal] entity User {
[Required, MaxLength(200), Unique] string Email;
security { allow read when IsAuthenticated; }
}
entity Organization { [Required, MaxLength(80)] string Name;
security { allow read when IsAuthenticated; } }
entity Membership { [Required] User User; [Required] Organization Organization; [Required] OrgRole Role;
security { allow read when IsAuthenticated; } }
// "is an admin of THIS organisation" — a fact about the caller AND a row.
policy IsAdmin(Organization o) => Membership.Any(m => m.User == user && m.Organization == o && m.Role == OrgRole.Admin);
entity RoleGrant {
[Required] Organization Organization;
[Required, MaxLength(40)] string Label;
security {
allow read when IsAdmin(Organization); // read: this row's Organization
allow create, update, delete where IsAdmin(Organization); // the same rule, the other word
}
}The create line is a with-check: a new RoleGrant may only be written for an organisation the caller
administers, decided against the row being created. A when that mixes a caller fact with a row fact —
when IsAuthenticated && IsAdmin(Organization) — keeps the caller half as the when and makes the row half the
where. And osy explain reads either spelling as the one sentence it is: "only rows of an Organization where the
caller's Membership has Role = Admin".
What the compiler still refuses, at the rule, naming what you passed and what would work:
- A field-level rule's
when—deny read Label when IsAdmin(Organization)— cannot be about the row: awhenguard runs once per request, before any row is in hand. Guardwhenon the caller alone, or write this rule's OWNwhereinstead — a field mask may carry a row-scoped condition, exactly like a row rule's (deny read Label where IsAdmin(Organization);, [[security-entity-security#field-mask-row-scoped]]). - An entity that is not a member —
IsAdmin(Organization)on an entity with noOrganizationproperty. There the name IS the entity set (everyOrganization), which a policy taking one row cannot take; the refusal says so and offers both ways out: pass a specific row, or add the relation and write the row rule.
What the compiler will not let you write#
- Naming a parameterised policy without its argument —
if (!CanManageTeam)— is an error. There would be nothing for it to decide about, and a rule that quietly decides about nothing denies people who should be allowed. - Giving the wrong number of arguments is an error, naming what the policy declares.
- A lambda variable that shadows the policy's own parameter —
CanManageTeam(Team t) => …Any(t => …)— is an error. One name would mean two different rows. - Gating a page on a parameterised policy —
[Authorize(CanManageTeam)]— is an error. A page gate runs before any row is loaded, so there is no argument to give it. Gate the page on a plain policy and check the row inside. - Two policies that call each other —
A(u) => B(u)andB(u) => A(u)— is an error. There is no fixed predicate to inline, and a rule with no fixed meaning cannot be enforced.
Examples#
entity Memo {
[Required] Team Team;
[MaxLength(200)] string Note;
security {
allow read when IsAuthenticated;
// Declared authority: the POLICY itself, passing the row it is about. This block used to spell the rule out
// again by hand — the page claimed the policy was used in all three places while one of them was a copy.
allow update, delete where CanManageTeam(Team);
}
}
// …and named in code, for the authority the engine cannot carry for you — an elevated operation, an external call,
// anything where the decision is not a row read.
bool MayManage(Guid teamId) {
var team = Team.Where(t => t.Id == teamId).FirstOrDefault();
return CanManageTeam(team);
}See also#
security { } — declaring who may read and write an entity's rows.
principal predicates (IsAuthenticated / IsAnonymous) and open reads — IsAuthenticated and the other built-in facts about the caller.
secure by default (deny-all) — why everything is denied until a rule grants it.