Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne 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.

stable7 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

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 whendeny read Label when IsAdmin(Organization) — cannot be about the row: a when guard runs once per request, before any row is in hand. Guard when on the caller alone, or write this rule's OWN where instead — 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 memberIsAdmin(Organization) on an entity with no Organization property. There the name IS the entity set (every Organization), 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 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…