Organization Roles
Organization roles control what members can do within an organization. They are scoped to a single organization and stored separately from application-level roles.
Permission catalog
Each application carries an independent organization permission catalog, seeded automatically when the application is created. The catalog is small and stable:
| Permission | Gates |
|---|---|
org:read | View the organization, its members, and pending invitations |
org:manage | Update organization settings; delete the organization |
org:members:write | Change member roles and remove members |
org:invitations:write | Send and revoke organization invitations |
These permission names appear in GET /api/organizations/:id and on GET /api/users/me (under active_org_permissions) so the dashboard can gate UI without an extra round-trip.
Preset roles
Two roles are seeded per application:
| Role | Permissions |
|---|---|
admin | org:read, org:manage, org:members:write, org:invitations:write |
member | org:read |
Preset roles cannot be renamed or deleted (they carry is_system = 1 in organization_roles).
Enforcement
Every organization-scoped route resolves the caller's role through organization_members, then walks organization_role_permissions to derive the permission set. Two response codes matter:
| Outcome | Code |
|---|---|
| Org does not exist OR the caller is not a member | 404 |
| Caller is a member but lacks the required permission | 403 |
The 404-for-non-members rule is intentional — it prevents cross-tenant probing for organization IDs.
Last-org:manage guard
The backend enforces that an organization always has at least one member holding org:manage. Demote and remove operations fail with 409 Conflict when the target is the sole org:manage holder. The check lives inside the mutation's SQL WHERE clause, so two concurrent self-demotes can't both succeed (a pure check-then-update would have been TOCTOU-vulnerable).
The guard keys off the permission, not the role string. Once custom roles ship, an org with two members each holding org:manage can still demote either of them — the invariant cares about the permission set, not the role name.
Role assignment
- When a user creates an organization, they're inserted with the
adminrole in the same database batch as the org row. - When a user accepts an invitation, they're assigned the role from the invitation (defaults to
member). - Members holding
org:members:writecan change another member's role from the Members tab.
Schema
Migration 0004_organization_rbac.sql adds:
organization_roles (id, app_id, name, description, is_system, …)
organization_permissions (id, app_id, name, description, …)
organization_role_permissions (app_id, role_id, permission_id)
organization_members.role_id -- nullable FK; populated by current writes
organization_invitations.role_id -- sameRole and permission IDs follow a deterministic naming scheme: orgrole:{app_id}:{name} and orgperm:{app_id}:{name}. This lets the migration backfill resolve role_id from the legacy role TEXT column via a simple string concatenation, and keeps the per-app seed idempotent.
Custom roles
Beyond the seeded presets, applications can define custom organization roles via the Organizations API or the Settings → Organization roles editor in the dashboard. A custom role is a name + description + arbitrary subset of the permission catalog.
Custom role definitions are app-scoped: once created, they're available in every organization in the application. The dashboard exposes the editor from inside an org's Settings tab (gated on org:manage), but the change applies app-wide.
Constraints:
- Custom role names must be unique per application and cannot collide with
admin/member(UNIQUE(app_id, name)backstops the pre-flight check). - Permissions must come from the seeded catalog — the API rejects unknown names with
400. - Preset (
is_system = 1) roles cannot be renamed, re-scoped, or deleted. Attempts return403. - A custom role can only be deleted when zero members hold it. Otherwise the API returns
409with anin_use_countpayload so the operator can reassign first.
Invite and member-role dropdowns load the role list dynamically, so newly created custom roles appear immediately.