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.
A parameter may also be the row's id — policy IsMyBasket(Guid basket) => Basket.Any(b => b.Id == basket && b.VisitId == Visitor.Id); — which is what a rule passes (where IsMyBasket(Id)) and what a page holds. Called in a body, it
answers exactly as it does in the rule: a comparison with nobody (no signed-in user, no visit) matches nothing.
A policy does not see the row it guards#
A policy's body is like a C# static method: the names in it are tables, enums, user, other policies and its own
parameters — never a field of the row a rule is guarding. That holds wherever the policy is named, so it means the
same thing on every entity, including one that happens to have a field with the same name as a table the policy reads:
[Principal] entity Person {
[Required, MaxLength(200), Unique] string Email;
security { allow read when IsAuthenticated; }
}
entity Desk {
[Required, MaxLength(60)] string Name;
Person? Agent;
security { allow read when IsAuthenticated; }
}
entity Ticket {
[Required, MaxLength(120)] string Subject;
Desk? Desk; // a field named after the table the policies below read
security {
allow read when StaffsADesk; // `Desk` in the body is the TABLE: "staffs any desk"
allow update where StaffsThisDesk(Desk); // …and the row's own field is passed in, by name
}
}
policy StaffsADesk => Desk.Any(d => d.Agent == user);
policy StaffsThisDesk(Desk desk) => Desk.Any(d => d == desk && d.Agent == user);So a rule that needs the row says so itself: pass the field as an argument (where StaffsThisDesk(Desk)), or write
that part in the rule's own where. A body that reaches for a field of a row it guards is an error at the policy,
naming both:
policy MyTicket => Requester == user; // ✗ 'Requester' is a field of Ticket, not something a policy can see
policy Requested(Person by) => by == user;
// on Ticket: allow read where Requested(Requester); ✓ the row hands it in
// or: allow read where Requester == user; ✓ the rule says it itselfNot the caller's row either. user is how a policy reads the signed-in person — a bare name is never one of their
fields, wherever the policy is used: in a security { } rule, in an [Authorize], or called from a function. So a
policy means the same thing even when the caller's own entity has a field named after a table it reads:
[Principal] entity Person {
[Required, MaxLength(200), Unique] string Email;
[ForeignKey(Member)] Desk[] Desk; // the caller's OWN desks — a field named after the table
security { allow read when IsAuthenticated; }
}
entity Desk {
[Required, MaxLength(60)] string Name;
Person? Member;
Person? Lead;
security { allow read when IsAuthenticated; }
}
policy LeadsADesk => Desk.Any(d => d.Lead == user); // ANY desk the caller leads: the table
bool AmILead() {
return LeadsADesk;
}A field of the caller is written through user, and a bare one is refused with that spelling:
policy IsVerified => EmailVerified; // ✗ 'EmailVerified' is a field of the caller's 'Person'
policy IsVerified => user.EmailVerified; // ✓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; } }
[RoleGrant] 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 |
A policy can be built from other policies#
Once the four rules above have names, the obvious next move is to name their combination — "may this person work on
this case at all" is itself a rule, and it is wanted in more than one place. So a policy body may call another
policy, exactly as a where may:
[Role] enum Level { Member, Finance, Admin }
[Principal] entity Person {
[Required, MaxLength(200), Unique] string Email;
security { allow read when IsAuthenticated; }
}
entity Org { [Required, MaxLength(60)] string Name;
security { allow read when IsAuthenticated; } }
[RoleGrant] entity Membership { [Required] Person Who; [Required] Org Org; [Required] Level Level;
security { allow read when IsAuthenticated; } }
entity Project { [Required] Org Owner; [Required, MaxLength(60)] string Code;
security { allow read when IsAuthenticated; } }
policy IsFinance(Org o) => Membership.Any(m => m.Who == user && m.Org == o && m.Level == Level.Finance);
policy IsAdmin(Org o) => Membership.Any(m => m.Who == user && m.Org == o && m.Level == Level.Admin);
// The composite, named once — and then used like any other policy.
policy MayApprove(Org o) => IsFinance(o) || IsAdmin(o);
// It may pass a DIFFERENT row than its own parameter, and reach across a relation to find one.
policy MayTouch(Project p) => MayApprove(p.Owner);
// …and it composes further, to any depth.
policy MayTouchButNotSpend(Project p) => MayTouch(p) && !IsFinance(p.Owner);
entity Invoice {
[Required] Project Project;
[Required, MaxLength(40)] string Reference;
security { allow read where MayTouch(Project); }
}The argument you write is spliced into the policy you called, so MayApprove(p.Owner) asks about that project's
org and not about anything else in scope. As with a where, nothing is given up: the persisted predicate is the
same welded expression either way.
⚠ The one thing a policy may not do is reach itself. A(o) => B(o) together with B(o) => A(o) is refused, and
so is A(o) => A(o); the message names each policy on the loop, at its own declaration, with the route it takes back
to itself (A → B → A). A policy is inlined into every rule that names it, so a cycle has no predicate to inline.
⚠ And the argument has to be the right kind of row. Handing a Project to a policy that is about an Org is
refused at the argument you wrote, naming both types and the relation that gets you from one to the other — "policy
'IsFinance' is about an Org (its parameter o), and p is a Project. Pass the Org it belongs to: p.Owner."
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; } }
[RoleGrant] 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.
Two packages, one name: say which#
IsAdmin and IsSalesPerson are exactly the names two publishers both pick, so a policy's name carries the
namespace it was declared in. An app that installs two packages which each declare IsAdmin names the one it means
in full:
security { allow read when Acme.Crm.IsAdmin; } // …and `Other.Kit.IsAdmin` is a different ruleThe bare name keeps working wherever only one declaration answers to it, which is nearly always. When more than one does, the bare name is an error naming every candidate — a security rule may not be decided by which file was read first. Declaring the same name in two packages is not itself a problem; only using it unqualified is.
The app's own policy wins its bare name in the app's own code, as a declaration in C#'s enclosing namespace beats
one a using imports. An app that declares policy IsAdmin => …; and uses a package that declares its own IsAdmin
means its own wherever it writes IsAdmin, and reaches the package's by its full name:
policy IsAdmin => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Admin);
security {
allow read when IsAdmin; // this app's own
allow update when Acme.Crm.IsAdmin; // the package's
}Inside the PACKAGE, the bare name the app shares is still an error — the package's rule would otherwise enforce the app's policy — so a package names its own by its full name, or picks a name an app is unlikely to share.
How does a package say "staff" without knowing the app's roles?#
A package's rules sometimes have to say WHO in the app's own terms — a conversations kit must keep internal notes from everyone who is not staff, and staff are the app's Agent and Lead, roles the package has never seen. The package declares the policy with no body, and the app that uses it defines it:
namespace Acme.Desk;
/// Who works the desk — the app's to say.
policy IsDeskStaff;
[Authority(Extensible)]
public entity Entry {
bool Internal;
security { deny read when !IsDeskStaff where Internal; }
}policy IsDeskStaff => RoleGrant.Any(g => g.Member == user && (g.Role == AppRole.Agent || g.Role == AppRole.Lead));Every reference — the package's rules and the app's alike — then means the app's definition. It is compile-checked and
cannot change at runtime, which a table of staff roles the app seeds could. An app that leaves it out is refused by
name (`Acme.Desk` needs this app to define `policy IsDeskStaff`), and so is a definition whose parameters
differ from the declaration. The bodyless form is package vocabulary: in an app's own source it is refused — the app
writes the body.
The answer may be another package's policy. A shop answers the site package's question with the shop's own staff, in one line:
policy ArrangesPages => IsShopStaff; // Osysharp.Blocks asks; Osysharp.Shop says who staff areA package's question with a default answer#
A package that adds a NEW question to a released version would break every app that uses it, if the app had to answer
before it compiled again. virtual gives the question a default — the answer every app gets until it says otherwise:
namespace Acme.Mailboxes;
/// Who manages the mailboxes — the app's admins, unless the app says otherwise.
virtual policy ManagesMailboxes => IsAdmin;
[Authorize(ManagesMailboxes)]
public void StopWatching(string address) { … }An app that defines policy ManagesMailboxes => …; decides, exactly as for a bodyless policy. An app that does not
gets the default as its own definition — lowered in the app, so IsAdmin there is the app's own IsAdmin. An app
that defines neither is refused naming both ways out: define ManagesMailboxes, or the IsAdmin its default names.
Like the bodyless form, virtual policy is package vocabulary, and an app's own source is refused it.
A default may be false — nobody, until the app says. That is the shape for a question every app need not answer:
Osysharp.Blocks asks virtual policy ArrangesPages => false;, so a shop that never arranges its pages compiles with
no line at all, and one that does answers once. A where written over such a question —
allow read when IsAuthenticated where OwnsListAddress(Email) over virtual policy OwnsListAddress(string email) => false; — admits no row until the app answers; it is not refused as a where that never reads the row
([[security-when-and-where#refused]]).
A default may be another question the package asks. It then takes that question's answer — the app's if it gave one, else that question's own default — so an app that defines neither still compiles:
namespace Acme.Desk;
/// Who opens the desk. Nobody, until the app says.
virtual policy OpensDesk => false;
/// Who pauses it: whoever opens it, until the app says otherwise.
virtual policy PausesDesk => OpensDesk;| the app writes | PausesDesk admits |
|---|---|
| nothing | nobody (OpensDesk's default) |
policy OpensDesk => IsOpener; | the openers |
policy OpensDesk => IsOpener; and policy PausesDesk => IsCloser; | the closers |
Only a default naming something that has no answer anywhere is refused — a policy of the app's the app never
wrote (IsAdmin, above), or a bodyless question. Defaults that lead back to each other (virtual policy A => B;
with virtual policy B => A;) are refused when the app is compiled, naming the ring, unless the app answers one of
them.
A package's own policy over the questions it asks#
A package can ask two questions and name their combination once. The combination is the package's policy — an app cannot replace it — and it means the app's answers everywhere it is named:
namespace Acme.Desk;
virtual policy OpensDesk => false;
virtual policy ClosesDesk => false;
/// Who runs the desk: whoever opens or closes it.
policy RunsDesk => OpensDesk || ClosesDesk;
[Server, Authorize(RunsDesk)]
public string Run() { … }
public entity Ticket {
security { allow read when RunsDesk; }
}
public component DeskPanel() {
render { if (RunsDesk) { Text("you run the desk"); } }
}| the app writes | RunsDesk admits |
|---|---|
| nothing | nobody |
policy OpensDesk => IsOpener; | the openers |
policy ClosesDesk => IsCloser; | the closers |
| both | the openers and the closers |
The function's [Authorize], the rule, a page's [Authorize(RunsDesk)], if (RunsDesk) in a body and the control on
screen all give the same answer. A question left at a constant default is reflected on a control as that constant: a
control behind false is never drawn. Two answers may name the same policy of the app's (policy OpensDesk => IsOpener; beside policy ClosesDesk => IsOpener || IsCloser;).
Two packages ask the same question: answer each by name#
Two packages may both ask Allowed — a credits package and a sessions package, each with its own virtual policy Allowed => …;. A bare answer answers every package that asks the name. To answer one package, name it — the
way C# implements one interface's member explicitly:
policy Crux.Credits.Allowed => Membership.Any(m => m.Member == user && m.Paid); // only Crux.Credits asks this one
policy Allowed => IsAuthenticated; // every other package asking `Allowed`Each package's own rules, [Authorize(Allowed)] functions and pages then get the answer that is theirs. A package you
answer neither way keeps its default. The app's own code names an answer the same way: [Authorize(Crux.Credits.Allowed)],
or if (Crux.Credits.Allowed) { … }.
Two refusals keep this honest. Two packages that default the same name differently, with no answer from the
app, are refused naming both (`Crux.Credits.Allowed` and `Crux.Sessions.Allowed` each ask this app `Allowed` and default it differently) — otherwise whichever package was read first would decide for the other. Two packages with
the same default share it. And an answer naming a package that does not ask that question is refused, listing the
packages that do.
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. - A policy that reaches itself —
A(u) => B(u)withB(u) => A(u), orA(u) => A(u)— is an error. There is no fixed predicate to inline, and a rule with no fixed meaning cannot be enforced. Each policy on the loop is named at its own declaration, with the route back to itself. Composition that does NOT loop is fine — see [[#composing]]. - Handing a policy the wrong kind of row is an error at the argument, naming both types and the relation that reaches the wanted one.
- A bare policy name that two declarations answer to is an error naming both qualified spellings — see [[#namespaced]]. Write the one you mean in full.
- A policy body that reads a field of a row it guards —
policy MyTicket => Requester == user;named on aTicket— is an error at the policy. A policy sees no row; pass the field in, or put that part in the rule'swhere— see [[#no-row]]. - A policy body that names one of the caller's fields bare —
policy IsVerified => EmailVerified;— is an error at the policy, naminguser.EmailVerified. The caller isuser, not a row the body stands in — see [[#no-row]].
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 / IsTheApp) 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.