Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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:

PermissionGates
org:readView the organization, its members, and pending invitations
org:manageUpdate organization settings; delete the organization
org:members:writeChange member roles and remove members
org:invitations:writeSend 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:

RolePermissions
adminorg:read, org:manage, org:members:write, org:invitations:write
memberorg: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:

OutcomeCode
Org does not exist OR the caller is not a member404
Caller is a member but lacks the required permission403

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 admin role 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:write can 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 -- same

Role 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 return 403.
  • A custom role can only be deleted when zero members hold it. Otherwise the API returns 409 with an in_use_count payload so the operator can reassign first.

Invite and member-role dropdowns load the role list dynamically, so newly created custom roles appear immediately.