Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Security

policy

policy Name[(Type param)] => <predicate>;

Names an authorization rule once so every place that needs it can say the name. A policy is a boolean about the caller, usable where security is declared and callable in ordinary code.

stable5 examples compiled by CIsecurityauthorization

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:

weldedcomposed
the recusal rule lives infive placesone
renaming Liftedfive edits, and a miss compilesone edit
a missing clause on entity fivesilentstill silent — but there is only one clause to miss
reading the ruleparse the expressionread 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 argumentif (!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 parameterCanManageTeam(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 otherA(u) => B(u) and B(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 readsIsAuthenticated and the other built-in facts about the caller.

secure by default (deny-all) — why everything is denied until a rule grants it.

Related

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

principal predicates (IsAuthenticated / IsAnonymous) and open reads

Two built-in `when` predicates say who a request is: `IsAuthenticated` is a signed-in user, `IsAnonymous` is an…

role grants (and the first admin)

A role is granted by an ordinary entity — any entity that has both a reference to your `[Principal]` and a property…

secure by default (deny-all)

Deny-all is the posture, and it is the only one: an entity that declares no `security { }` block is denied to every…