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

Reference / Security

role grants (and the first admin)

entity RoleGrant { [Required] User User; [Required] AppRole Role; } // a [Principal] ref + a [Role] enum = a grant

A role is granted by an ordinary entity — any entity that has both a reference to your `[Principal]` and a property typed as your `[Role]` enum is a role grant, recognised by its shape, with no marker to remember. Which means the security question is not "who may be an admin" but "who may CREATE a row in that table" — and that is a `security { }` block like any other. This page covers the shape, the union of several grant tables, the first-signup-becomes-admin bootstrap, and the rule that keeps it from becoming a self-elevation hole.

stable3 examples compiled by CIsecurityauthorizationroles

Summary#

A role grant is an ordinary entity. Any entity that has both a reference to your [Principal] and a property typed as your [Role] enum is a grant table — recognised by that shape, with no marker to remember and no special grammar:

[Role] enum AppRole { Authenticator, Member, Admin }

[Principal]
entity User {
  [Required, MaxLength(255)] string Email;
  [MaxLength(200)] string? PasswordHash;
  security {
    allow read   when IsAuthenticator;
    allow create when IsAuthenticator;
    allow read where Id == user.Id;
    deny read PasswordHash when !IsAuthenticator;
  }
}

entity RoleGrant {
  [Required] User User;                       // ← a [Principal] reference
  [Required] AppRole Role = AppRole.Member;   // ← a [Role] enum property   ⇒ this table grants roles
  security {
    allow read where User == user;            // you may see your own role
    allow read when IsAdmin;
    allow create, update, delete when IsAdmin;   // ONLY an existing admin hands out roles…
    allow create when IsAuthenticator;           // …and the sign-up flow, for the first-admin bootstrap
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Role == AppRole.Authenticator);
policy IsAdmin         => RoleGrant.Any(g => g.User == user && g.Role == AppRole.Admin);

Everything else follows from that. The question "who may be an admin?" is the question "who may create a row in RoleGrant?" — and that is answered by a security { } block, in the same language as the rest of your app.

Signature#

[Role] enum <RoleEnum> { <Member>, … }        // the app's ONE role vocabulary

entity <AnyName> {                             // the shape, not the name, is what makes it a grant
  <PrincipalEntity> <ref>;                     //   a reference to the [Principal]
  <RoleEnum> <prop>;                           //   a property typed as the [Role] enum
  security { … }                               //   ← who may write a grant. This is the security decision.
}

policy <Name> => <Grant>.Any(g => g.<ref> == user && g.<prop> == <RoleEnum>.<Member>);

Description#

How does the engine find my grant table?#

There is no [RoleGrant] attribute, because there does not need to be: an entity that references the principal and carries the role enum can be nothing else. Name it RoleGrant, Membership, PlatformRoleGrant — the engine finds it by its shape and reads a user's roles from it.

An app may have several grant tables, and a user's roles are the union of all of them. A global RoleGrant plus a project-scoped ProjectMember is an ordinary thing to want, and it works without ceremony.

A property typed as an ordinary enum is not a role — it is data, and that is usually what you want.

enum OrgRole { Owner, Admin, Member }              // a plain domain enum — NOT the app's [Role] enum

entity OrgMember { User User; Organization Org; OrgRole Role; }   // not a role GRANT — it is membership data

OrgMember is a perfectly good entity and Owner/Admin are perfectly good values. They simply live in your data rather than in the caller's ticket — and a scoped tier (admin of Acme) has to live there, because a role name carries no scope. You use them exactly as you would any other data, in a where filter:

allow update where OrgMember.Any(m => m.Org == Org && m.User == user && m.Role != OrgRole.Member);

You may also use such a membership check as a when guard or an [[ui-authorize|[Authorize]]] policy — it is answered by looking for the row:

policy IsOrgAdmin => OrgMember.Any(m => m.User == user && m.Role != OrgRole.Member);   // "admin of ≥1 org"

But be precise about what such a policy can mean. A when guard and [Authorize] are asked before any row is in hand, so they can only answer questions about the person — "is this user an admin of some org" — never "…of this org". The per-org half is a question about a row, and it belongs in a where. Gate the page coarsely; scope the data by row. (The full split is in security { }.)

An app has exactly ONE [Role] enum#

It is tempting to mark OrgRole as [Role] too, so that org-admins get a "real" role. You cannot: a second [Role] enum is a compile error.

[Role] enum PlatformRole { Authenticator, User, Admin }
[Role] enum OrgRole      { Owner, Admin, Member }
//     ^^^^ an application may declare only ONE `[Role]` enum — `PlatformRole` is already the app's role
//          vocabulary, so `[Role]` here grants nothing. A second tier of membership is ordinary data:
//          drop the attribute and write a policy over the membership entity.

The rule is worth understanding rather than just obeying, because it tells you what a role is:

[Role] is the vocabulary the login ticket carries. One enum, resolved once, naming what the person is — platform-wide, unscoped. That is why a second one cannot simply be added alongside it: a principal's roles are a flat list of names, so merging two vocabularies would collapse OrgRole.Admin and PlatformRole.Admin into the same name — and IsPlatformAdmin, which asks whether the caller holds Admin, would answer yes to the admin of any throwaway org. Anyone could make themselves a platform admin by creating an organisation. The compiler refuses the second enum so that nobody is ever tempted to "fix" it that way.

And you do not need one. A scoped tier could not be a role anyway — a role name carries no scope, so "admin of Acme" is unsayable in a flat list, and only a row can hold it. Membership is the natural home for that, and a policy over it is a first-class rule: it works in a security { } block and in [Authorize(…)] alike, exactly as shown above. Nothing is lost by keeping OrgRole a plain enum — the tier is more expressive as data than it ever could have been as a role.

The security decision is on the grant table#

If any signed-in user could create a RoleGrant row naming themselves and Admin, then every user is an admin, and every other rule in your app is decoration. So the grant table's security { } block is the most consequential one you will write. The shape that works:

security {
  allow read where User == user;               // see your own role
  allow read when IsAdmin;                     // an admin sees who holds what
  allow create, update, delete when IsAdmin;   // only an existing admin grants a role
}

Note what is absent, and how deliberately: there is no allow create where User == user. That line would read innocently — "a user may create their own grant" — and it would let anyone make themselves an admin. A grant is never self-written. The authority to hand out a role comes from already having the authority, which is what stops the ladder from being climbable from the ground.

And note that update and delete are listed explicitly. A rule that guards create and forgets update lets a Member grant be edited into an Admin one — the same hole through a different verb. Grant all three to the admin, or none.

The bootstrap problem: where does the FIRST admin come from?#

Only an admin may grant Admin. On an empty database there is no admin — so nobody can ever become one. Something must break the circle, and it must break it exactly once.

The signup flow is the natural place, because the very first account is the only moment the answer is unambiguous:

[AuthMethod]
string Signup(string email, string password) {
  bool isFirst = User.Count() == 0;             // ← evaluated BEFORE the create, or it is never true

  var u = new User { Email = email, PasswordHash = Security.HashPassword(password) };
  if (isFirst) {
    var grant = new RoleGrant { User = u, Role = AppRole.Admin };
  }
  return Security.IssueJwt(u.Id, u.Email);
}

[AuthMethod]
string Login(string email, string password) {
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  if (u == null) { return ""; }
  if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup };

Three details in that function are load-bearing, and each of them is a bug if you get it wrong:

  1. isFirst is captured before the create. Read it afterwards and the count is 1, never 0 — the app would have no admin, ever, and the failure would look like a permissions problem rather than an ordering one.
  2. User.Count() is honest. Like every query, it counts only the rows the caller may read — so this works because the auth role was granted a read on User. An auth role that could not read User would count 0 every time, and every signup would mint an admin. The grant is not a formality; it is what makes the count mean what you think it means.
  3. The allow create when IsAuthenticator on RoleGrant is what permits the grant — and it is the only reason this line is allowed to work. The signup flow runs as the ephemeral principal, so it writes under the auth role's grants like anything else.

Why this is not a self-elevation hole#

allow create when IsAuthenticator looks alarming at first — the sign-up path may mint a role grant! Read what actually holds it in place:

  • The auth role is not something a user can be. It is an ephemeral identity the engine mints, with no user behind it, and only for a caller with no ticket, and only while running one of the functions you wired into app.AuthBootstrap. A signed-in user cannot acquire it — invoking Login while authenticated does not elevate them.
  • So the reachable surface of that grant is exactly the body of Signup — a function you wrote, that you can read on one screen, and that mints Admin only when the user table is empty.
  • There is no other path. No ordinary user, and no org-admin managing their own members, can write a RoleGrant — because no rule grants them create. The authority to make an admin is held by exactly two things: an existing admin, and a one-shot bootstrap that stops working the moment it succeeds.

That is the invariant worth keeping as you extend the app: a lower tier must never be able to grant a higher one. When you add an org-membership table, an app-role table, an invite flow, ask the question again each time — the hole is never the rule you wrote, it is the verb you forgot.

The test that proves a member cannot self-elevate#

This is a rule you should not merely believe. runas lets a test assert the denial directly — that an ordinary member cannot make themselves an admin:

[Test]
void A_member_cannot_grant_themselves_admin() {
  var member = new User { Email = "m@example.com" };
  var grant  = new RoleGrant { User = member, Role = AppRole.Member };

  runas(member) {
    Assert.Denied(() => new RoleGrant { User = member, Role = AppRole.Admin });
  }
}

See also#

Related

auth bootstrap (login, before anyone is signed in)

Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap`…

[AuthMethod] — a function an unauthenticated visitor may call

`[AuthMethod]` marks a sign-in function — login, signup, password-reset — as reachable by a visitor who is not signed…

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…

The security model

How authorization works in Osy#, end to end. Everything is denied until you grant it; a grant is compiled into every…