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 dataOrgMember 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:
isFirstis 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.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 onUser. An auth role that could not readUserwould 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.- The
allow create when IsAuthenticatoronRoleGrantis 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 — invokingLoginwhile 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 mintsAdminonly 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 themcreate. 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#
- auth bootstrap (login, before anyone is signed in) — the ephemeral principal the signup bootstrap runs as, and what leashes it
- [AuthMethod] — a function an unauthenticated visitor may call — the
[AuthMethod]marker onLogin/Signup - security { } —
when(the person) vswhere(the row), and the four verbs - The security model — the security model end to end
- runas — proving a rule denies the person it should