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 |
⚠ 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).
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.