# 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 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.

<!-- id: security-role-grants · area: security · stability: stable · html: https://osysharp.com/reference/security/role-grants/ -->

## Summary        {#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:

```osy title="this is a role grant, because of its shape" test app=security-role-grants
[Role] enum AppRole { Authenticator, Member, Admin }

[Principal]
entity User {
  [Required, Unique, 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      {#signature}
```osy syntax
[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    {#description}

### How does the engine find my grant table?   {#by-shape}
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.

**The user reference must be the GRANTEE** — the person the row is about. A reference named `User`, `Grantee`,
`Member`, `Principal`, `Subject`, `Account`, `Holder` (or after your principal entity itself) is one; so is the
entity's only user reference, unless its name says it is an *actor* — a `…By` suffix (`InvitedBy`, `CreatedBy`,
`ApprovedBy`) or an agent noun (`Inviter`, `Creator`, `Author`, `Approver`, `Reviewer`, `Requester`, `Sender`,
`Granter`, `Assigner`, `Issuer`, `Reporter`, `Actor`). So `Invitation { Organization Org; string Email; OrgRole
Role; User InvitedBy; }` is **not** a grant table: it carries a user reference beside a role, but the reference is
the inviter, and an invitation grants nobody anything. The linter, the role resolver and `osy user add` share this
one rule, so they can never disagree about which tables grant.

**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.**

```osy title="a plain enum on an entity is membership data, not a grant" syntax
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:

```osy title="scoped membership belongs in a where filter" syntax
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:

```osy title="the same check as a policy — about the person, not this org" syntax
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 { }](https://osysharp.com/reference/security/entity-security/).)

### An app has exactly ONE `[Role]` enum   {#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.**

```osy syntax
[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   {#the-decision}
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:

```osy syntax
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.

`osy lint` holds this line for you: `security-grant-write-unguarded` is a MUST on any write to a grant table that
**the person the grant names can perform by being named** — `allow update where User == user`, a bare `allow create`,
a `where` that never mentions `user` at all (`where Role != Role.Admin` narrows which *rows*, never which *callers*).
It judges what the guard **says**, not which keyword it uses — which matters the moment a grant carries a scope.

### Who may write a grant that belongs to an organisation?   {#tenant-scoped}
In a multi-organisation app the grant table carries the tenant it applies to, and "an admin" means *an admin of
**this** row's organisation* — not an admin somewhere. That is a question about the row, so it cannot be a `when`
(a `when` is asked before any row is in hand, and has no `Organization` to read). It **has** to be a `where`, and a
`where` here is exactly as sound as the `when` above, because the person the grant names cannot satisfy it by being
named — holding an admin membership is a fact about a *different* table:

```osy title="an org-scoped grant table, guarded by the caller's membership in THIS row's organisation" test app=security-role-grants-tenant
[Role] enum Role { Authenticator, Member, Admin }
enum OrgRole { Admin, Member }                     // the tenant tier — plain data, not the app's [Role]

[Principal]
entity User {
  [Required, Unique, MaxLength(255)] string Email;
  [MaxLength(200)] string? PasswordHash;
  security {
    allow read   when IsAuthenticated;
    allow read, create when IsAuthenticator;
    deny read PasswordHash when !IsAuthenticator;
  }
}

entity Organization {
  [Required("Give the organisation a name."), MaxLength(100)] string Name;
  security { allow read when IsAuthenticated; }
}

entity Membership {                                 // who holds which tier, in which organisation
  [Required("Pick the person.")] User User;
  [Required("Pick the organisation.")] Organization Organization;
  [Required("Pick a tier.")] OrgRole Role;
  security { allow read when IsAuthenticated; }
}

// "The caller holds Admin in THIS organisation" — a rule ABOUT a row, so it takes one.
policy IsAdmin(Organization o) => Membership.Any(m => m.User == user && m.Organization == o && m.Role == OrgRole.Admin);
policy IsAuthenticator        => RoleGrant.Any(g => g.User == user && g.Role == Role.Authenticator);

entity RoleGrant {
  [Required("Pick the organisation.")] Organization Organization;   // ← the scope
  [Required("Pick the person.")] User User;                          // ← a [Principal] reference
  [Required("Pick a role.")] Role Role;                              // ← a [Role] enum property ⇒ a grant table
  security {
    allow read where User == user;                       // see your own roles
    allow read, create, update, delete where IsAdmin(Organization);   // an admin OF THIS ROW'S organisation
    allow create when IsAuthenticator;                   // the sign-up flow, for the first-admin bootstrap
  }
}
```

The check may also sit inline — `allow create, update, delete where Membership.Any(m => m.User == user &&
m.Organization == Organization && m.Role == OrgRole.Admin);` reads the same and lints the same; the named form is
just the one you can reuse on `Invitation`, `Budget` and everything else that hangs off an organisation
([policy](https://osysharp.com/reference/security/naming-a-policy/)).

What the linter reads, in either spelling: **can the row's own subject get through this predicate while holding no
grant, membership or ownership anywhere?** `User == user` — yes, by definition, so it is refused. `Membership.Any(m
=> m.User == user && …)` — no, so it is sound. And `User == user || IsAdmin(Organization)` is refused again: the `||`
lets the subject through whatever stands beside it.

### How does the FIRST admin of a FRESH tenant get seated?   {#the-founding-shape}
`IsAdmin(Organization)` above has a hole in it that only shows up once: a brand-new organisation has no Membership
and no RoleGrant row yet, so `IsAdmin(Organization)` is FALSE for **everyone** — including the person who just
created it. Nobody could ever grant themselves (or anyone) the first role in an organisation they just founded.

The natural rule — "you may grant yourself Admin, but only if this organisation has no grant yet" — is SOUND, and
it is sound for a reason the compiler enforces rather than one you have to trust:

```osy title="founding an organisation makes you its admin" test app=security-role-grants-founding
[Role] enum Role { Authenticator, Member, Admin }

[Principal]
entity User {
  [Required, Unique, MaxLength(255)] string Email;
  [MaxLength(200)] string? PasswordHash;
  security {
    allow read when IsAuthenticated;
    deny read PasswordHash when !IsAuthenticator;
  }
}

entity Organization {
  [Required("Give the organisation a name."), MaxLength(100)] string Name;
  security {
    allow read when IsAuthenticated;
    allow create when IsAuthenticated;   // anyone may found one — RoleGrant's own guard is what makes that safe
  }
}

entity RoleGrant {
  [Required("Pick the organisation.")] Organization Organization;
  [Required("Pick the person.")] User User;
  [Required("Pick a role.")] Role Role;
  security {
    allow read where User == user;
    allow read, create, update, delete where RoleGrant.Any(g => g.User == user && g.Organization == Organization && g.Role == Role.Admin);
    // THE FOUNDING SHAPE: you may grant YOURSELF Admin, but only where this organisation holds NO grant yet.
    allow create where User == user && Role == Role.Admin && !RoleGrant.Any(g => g.Organization == Organization);
  }
}

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

Three things make this the ONE self-write the linter's `security-grant-write-unguarded` MUST accepts, and it
accepts nothing looser:
1. **The caller names themselves** — `User == user` — because a founding grant can only ever be for the founder.
2. **The role is ONE specific, literal member** — `Role == Role.Admin` — never "whatever role the caller asks for".
3. **The existence check is SELF-referential and scoped to nothing but the row's own tenant column** —
   `!RoleGrant.Any(g => g.Organization == Organization)`, not `g.User == user` (that checks the CALLER's history,
   which lets a fresh attacker re-target an organisation someone else already founded) and not a filter on some
   other field (`g.Role == Role.Member`, say — that blocks re-entry only once THAT filter's condition holds, not
   once any admin grant does).

That third point is what makes this SOUND rather than merely plausible: a CREATE's own guard is
evaluated with the row being inserted **excluded** from its own `.Any(…)` — so the very first `RoleGrant` for an
organisation sees no rows and passes, and the moment it commits, every later attempt against the SAME organisation
sees that row and is refused. The guard can pass for a given organisation **at most once**, no matter who tries or
how many times:

```osy title="the first founder becomes admin; a second founder in the same organisation is refused" run app=security-role-grants-founding
[TestFixture]
void OneFoundedOrgAndTwoUsers() {
  var acme  = new Organization { Name = "Acme" };
  var alice = new User { Email = "alice@example.com" };
  var bob   = new User { Email = "bob@example.com" };
  new RoleGrant { Organization = acme, User = alice, Role = Role.Admin };   // Acme is already founded
}

principal Bob => User.Single(u => u.Email == "bob@example.com");

[Test(OneFoundedOrgAndTwoUsers)]
[runas(Bob)]
void a_fresh_organisation_can_be_founded_by_its_first_admin() {
  var fresh = new Organization { Name = "Fresh Co" };
  var me = User.Single(u => u.Email == "bob@example.com");
  var grant = new RoleGrant { Organization = fresh, User = me, Role = Role.Admin };
  Assert.Equal(Role.Admin, grant.Role);
}

[Test(OneFoundedOrgAndTwoUsers)]
[runas(Bob)]
void a_second_founder_in_the_same_organisation_is_refused() {
  var acme = Organization.Single(o => o.Name == "Acme");
  var me = User.Single(u => u.Email == "bob@example.com");
  Assert.Denied(() => new RoleGrant { Organization = acme, User = me, Role = Role.Admin });
}
```

If you would rather nobody self-founds at all — every organisation is created BY an operator, for a customer —
see [[#platform-superadmin]] below.

### Can an OPERATOR create tenants, without any self-service rule at all?   {#platform-superadmin}
The other sanctioned shape has no privilege-escalation reasoning to check, because nothing in it is
self-written: a platform-level role, held by whoever runs the platform, gates tenant creation directly. Seed it
with `osy user add <email> --role SuperAdmin` — an OPERATOR verb, run once against a fresh instance, never a row
your app's own code writes.

```osy title="an operator-only role that creates tenants — no self-service, no founding rule to reason about" test app=security-role-grants-superadmin
[Role] enum PlatformRole { Authenticator, SuperAdmin, Member }

[Principal]
entity User {
  [Required, Unique, MaxLength(255)] string Email;
  [MaxLength(200)] string? PasswordHash;
  security {
    allow read when IsAuthenticated;
    allow read, create when IsAuthenticator;
    deny read PasswordHash when !IsAuthenticator;
  }
}

entity RoleGrant {
  [Required] User User;
  [Required] PlatformRole Level = PlatformRole.Member;
  security {
    allow read where User == user;
    allow read when IsSuperAdmin;
    allow create, update, delete when IsSuperAdmin;   // only an existing SuperAdmin hands out roles…
    allow create when IsAuthenticator;                // …and the sign-up flow, for the first-admin bootstrap
  }
}

entity Organization {
  [Required("Give the organisation a name."), MaxLength(100)] string Name;
  security {
    allow read when IsAuthenticated;
    allow create when IsSuperAdmin;   // only the operator (or a SuperAdmin they seated) may found a tenant
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Level == PlatformRole.Authenticator);
policy IsSuperAdmin    => RoleGrant.Any(g => g.User == user && g.Level == PlatformRole.SuperAdmin);
```

`RoleGrant` here carries no `Organization` at all — `PlatformRole` is a GLOBAL vocabulary, exactly like the
app-wide `[Role]` enum every app already has ([[#one-role-enum]]); a `SuperAdmin` is not an admin of any one
tenant, they are the operator. `allow create when IsSuperAdmin;` on `Organization` needs no `where`, no existence
check and no founding reasoning, because the caller is never the row's own subject — the whole self-elevation
question this page spends most of its words on simply does not arise. The trade is product, not security: nobody
signs themselves up for a tenant; an operator (or a SuperAdmin they seated) creates one for them.

### The bootstrap problem: where does the FIRST admin come from?   {#first-admin}
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:

```osy title="the first account bootstraps the admin; every later one does not" test app=security-role-grants
[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();
  // Spend the same time either way — an early return makes "no such account" measurably faster
  // than "wrong password", and that difference is an enumeration oracle anyone can time. The
  // one-argument form verifies against nothing, costs a full KDF, and answers false.
  if (u == null) { Security.VerifyPassword(password); 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](https://osysharp.com/reference/security/auth-bootstrap/), so it
   writes under the auth role's grants like anything else.

### Why this is not a self-elevation hole   {#no-self-elevation}
`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](https://osysharp.com/reference/security/auth-bootstrap/) 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   {#prove-it}
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.

**Read the seed first: it is under a `runas` too, and it has to be.** A `[Test]` body outside a `runas` block is an
**anonymous caller** — not an exempt authoring context — so `new User { … }` there is subject to
`allow create when IsAuthenticator` exactly like the same line in a function, and is refused. The identity that may
mint a `User` and their first grant is the one the sign-up flow itself runs as, and
[`runas (AuthBootstrap)`](https://osysharp.com/reference/testing/runas/) is how a test stands there. (The other place a seed may be written
unsecured is a [[testing-test|`[TestFixture]`]], which runs unrestricted by design — but *only* the fixture; the rule
above is what holds inside a `[Test]`.)

```osy title="the test that proves a member cannot self-elevate" run app=security-role-grants
[Test]
void A_member_cannot_grant_themselves_admin() {
  // The seed is a WRITE, and a [Test] body is NOBODY — so these two creates obey `allow create when
  // IsAuthenticator` just as they would in the app. AuthBootstrap is the ephemeral principal the sign-up flow
  // runs as, and in this app it is the only identity that may mint a User and their first grant.
  User? member = null;
  runas (AuthBootstrap) {
    member = new User { Email = "m@example.com" };
    var membership = new RoleGrant { User = member, Role = AppRole.Member };
  }

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

## See also       {#see-also}
- [auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/) — the ephemeral principal the signup bootstrap runs as, and what leashes it
- [[AuthMethod] — a function an unauthenticated visitor may call](https://osysharp.com/reference/security/auth-method/) — the `[AuthMethod]` marker on `Login` / `Signup`
- [security { }](https://osysharp.com/reference/security/entity-security/) — `when` (the person) vs `where` (the row), and the four verbs
- [The security model](https://osysharp.com/reference/security/index/) — the security model end to end
- [runas](https://osysharp.com/reference/testing/runas/) — proving a rule denies the person it should
