Keel

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

ts
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:

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

Edit this page on GitHub →Last updated Jul 19, 2026