Docs / Teams & RBAC / Permissions
Permissions
Every team-scoped route in Keel checks one thing before it does anything else: does this user, in this team, have the permission this action requires. That check lives in lib/permissions.ts, and it's the same function whether you're calling it from a server route or a client component.
Roles
Keel ships three per-team roles, plus one global role that sits outside any team:
- owner, full control, including deleting the team and transferring billing.
- admin, everything except deleting the team or promoting another admin to owner.
- member, read access plus whatever individual permissions you grant.
- superadmin, a global role for your own internal admin panel, unrelated to team membership.
The can() helper
import { can } from "@/lib/permissions";
if (!can(role, "team:invite")) {
throw forbidden();
}
can() takes a role and a permission string and returns a boolean, no database call, no async. The full list of permissions and which roles hold them lives in the same file, as a flat matrix, not scattered across route handlers.
Rank, not just role
Some checks aren't "can this role do X," they're "can this role do X to that role." Demoting an admin to member is fine for an owner, not fine for another admin. That's what outranks() is for:
if (!outranks(actor.role, target.role)) {
throw forbidden();
}
Why this is two functions, not one
A flat permission table can answer "does this role have this permission" but not "is this role allowed to act on that other role." Keeping rank logic separate from the permission matrix avoids encoding relative comparisons into every role's permission list.