RBAC (Role-Based Access Control)

Fine-grained access control with roles, permissions, policies, departments, and groups. All authorization is scoped to the organization level.

After reading this page you will be able to: build a least-privilege custom role from the system permission catalog, understand exactly which permissions a custom role may and may not hold, layer allow and deny policies on top of roles, model your org chart with departments and groups, and read the fully computed set of permissions any user resolves to. Everything here is scoped to one organization; a caller can never touch another organization’s roles or permissions.

Roles

All RBAC endpoints scope to the authenticated user’s organization, which is resolved from the session; the organization is never passed in the body or query string. There are two kinds of role:

  • Built-in roles ship with the platform (for example owner, admin, member). They are read-only: you cannot create, edit, or delete them, and a custom role may not reuse a built-in role’s key.
  • Custom roles are the ones you create. They are always non-system, are bounded to the permissions your organization is allowed to delegate, and are the focus of the next section.

List Roles

GET /v1/rbac/roles

Query Parameters

ParameterTypeDescription
searchstringFilter roles by name
limitintegerMax results (max: 100)
offsetintegerPagination offset

Response

{
  "data": {
    "roles": [
      {
        "key": "admin",
        "name": "Administrator",
        "description": "Full organization access",
        "is_system": true,
        "created_at": "2026-01-01T00:00:00Z"
      }
    ],
    "pagination": {
      "total": 6,
      "limit": 20,
      "offset": 0
    }
  }
}

Create Role

POST /v1/rbac/roles

Creating or updating a custom role requires the manage-roles permission. The role key is generated by the platform; custom roles are always non-system.

Request Body

{
  "name": "Project Lead",
  "description": "Manages projects and assigns tasks",
  "permission_ids": ["project.view", "project.manage", "task.assign"]
}

permission_ids is a list of permission slugs (dot-notation text keys such as project.view), not UUIDs. Up to 200 may be supplied. The organization is taken from the authenticated session. System-assigned fields (id, key, is_system, created_at, updated_at) are never supplied in the request body; they are assigned by the platform. Any attempt to set is_system is rejected with a 400.

How permissions are validated

Every slug in permission_ids is checked against the system permission catalog. Two rules govern what a custom role may hold, and both are the privilege-escalation containment boundary that a security reviewer will ask about:

  • The slug must exist in the catalog. An unknown or misspelled slug fails the whole request with 400 and code INVALID_INPUT (the message lists the unknown keys). The request is all-or-nothing: one bad slug rejects the entire role.
  • You can only delegate what you hold. A custom role may only grant permissions you yourself hold through your own roles. Requesting a permission you do not hold fails with 403 FORBIDDEN. This is what stops a custom role from being used to escalate beyond your own access.

Use List Permissions to discover the exact slugs available to you before you build a role, so you never guess a key.

Hierarchy level

A custom role’s hierarchy_level is bounded to 1 through 99. Level 100 is reserved and is rejected. Lower-privilege roles sit lower in the hierarchy.

Response

Returns the full created role record on success.

{
  "success": true,
  "data": {
    "id": "...",
    "key": "project_lead",
    "org_id": "...",
    "name": "Project Lead",
    "description": "Manages projects and assigns tasks",
    "hierarchy_level": 50,
    "is_system": false,
    "permissions": ["project.read", "project.write", "task.assign"],
    "created_at": "2026-04-13T10:00:00Z",
    "updated_at": "2026-04-13T10:00:00Z"
  }
}

Response Codes

StatusMeaning
201 CreatedRole created; the full record is returned
400 Bad RequestValidation error: missing/invalid name, an unknown permission slug, or an out-of-range hierarchy level
401 UnauthorizedAuthentication required or invalid
403 ForbiddenCaller lacks the manage-roles permission, or the role requests a permission the caller does not hold
409 ConflictA role with this name already exists in the organization (or collides with a built-in role)
500 Internal Server ErrorUnexpected server error

Get Role

GET /v1/rbac/roles/:id

The :id in the path is the role’s text slug key (for example project_lead), not a UUID. Both built-in and custom roles are addressed by their slug.

Update Role

PUT /v1/rbac/roles/:id

Request Body

{
  "name": "Senior Project Lead",
  "permission_ids": ["project.read", "project.write", "project.delete", "task.assign"]
}

Delete Role

DELETE /v1/rbac/roles/:id

Built-in roles cannot be deleted. A custom role can only be deleted when no members are currently assigned to it; reassign its members first. A custom role key may also never collide with a built-in role key: attempting to create one that shadows a built-in role returns 409 with code DUPLICATE.

The Roles tab of the Identity and Access Management settings screen. Built-in roles such as Guest and Viewer carry a System role badge; callout 2 marks their Edit and Delete controls, which are visibly disabled. Custom roles carry no System role badge; callout 1 marks a custom role's Edit and Delete controls, which are enabled. A New Role button sits at the top right.
Built-in roles (marked System role) cannot be edited or deleted; their controls are disabled. Custom roles you create have enabled Edit and Delete controls.

Custom-Role Error Reference

When building or editing a custom role, map the response to the cause and the fix:

Status & codeCauseFix
400 INVALID_INPUTA permission slug is not in the system catalog, the name is missing, or the hierarchy level is outside the 1 to 99 rangeCorrect the slug (list the catalog), name, or level
403 FORBIDDENThe caller lacks manage-roles, or the role requests a permission the caller does not themselves holdGrant manage-roles, or drop any permission the caller does not hold from the role
409 DUPLICATEThe role name or key collides with an existing role or a built-in roleChoose a distinct name

Permissions

Permissions are a fixed system catalog managed by the platform. You do not create or delete permissions; you reference the ones that already exist by their dot-notation slug (for example project.view, org.manage_roles) when you build a custom role or write a policy. List the catalog to discover the exact slugs available to your organization.

List Permissions

GET /v1/rbac/permissions

Query Parameters

ParameterTypeDescription
searchstringFilter permissions by name
limitintegerMax results (max: 100)
offsetintegerPagination offset

Response

{
  "data": {
    "permissions": [
      {
        "key": "project.view",
        "name": "View Projects",
        "description": "Read access to projects",
        "category": "project"
      }
    ]
  }
}

Each entry’s key is the slug you pass in a role’s permission_ids or a policy’s scope. A slug that is not in this catalog is rejected when you try to use it.

Effective Permissions

GET /v1/rbac/effective-permissions

Query Parameters

ParameterTypeDescription
user_idUUIDTarget user (defaults to authenticated user; querying another user requires admin/owner)
project_idUUIDScope evaluation to a specific project (optional)

Returns the computed set of permissions for a user within the organization, after evaluating all role assignments, group memberships, department inheritance, and explicit policy overrides. Deny policies take precedence over allow policies.

Response

{
  "data": {
    "user_id": "...",
    "org_id": "...",
    "permissions": [
      "project.read",
      "project.write",
      "task.read",
      "task.assign",
      "document.read"
    ],
    "roles": ["project_lead", "member"],
    "groups": ["engineering"],
    "departments": ["product"]
  }
}

Policies

List Policies

GET /v1/rbac/policies

Query Parameters

ParameterTypeDescription
searchstringFilter policies by name
resource_typestringFilter by resource type
statusstringFilter: active, inactive, all
limitintegerMax results (max: 100)
offsetintegerPagination offset

Create Policy

POST /v1/rbac/policies

Request Body

{
  "policy_name": "Restrict financial entity deletion",
  "effect": "deny",
  "subject_type": "role",
  "subject_id": "member",
  "operation": "DELETE",
  "resource_type": "entities:financial:*"
}

Policy Fields

FieldTypeDescription
policy_namestringPolicy name (required; name is also accepted)
effectstringallow or deny. Deny takes precedence.
subject_typestringuser, role, group, department, or org
subject_idstringThe ID or key of the subject (may be null for role-scoped defaults)
operationstringOne of CREATE, READ, UPDATE, DELETE, *. (Or supply an actions array / action_pattern.)
resource_typestringResource pattern with wildcard support (e.g., entities:*). (resource_pattern is also accepted.)

The organization is taken from the authenticated session. System-assigned fields (id, is_active default, version, created_by, created_at, updated_at) are never supplied in the request body; they are assigned by the platform.

Response

Returns the full created policy record on success.

{
  "success": true,
  "data": {
    "id": "...",
    "org_id": "...",
    "policy_name": "Restrict financial entity deletion",
    "display_name": null,
    "description": null,
    "evaluation_order": 0,
    "priority": 100,
    "operation": "DELETE",
    "resource_type": "entities:financial:*",
    "resource_filter": {},
    "subject_type": "role",
    "subject_id": "member",
    "subject_filter": {},
    "effect": "deny",
    "actions": ["*"],
    "conditions": {},
    "is_active": true,
    "is_system_protected": false,
    "version": 1,
    "created_by": "...",
    "created_at": "2026-04-13T10:00:00Z",
    "updated_at": "2026-04-13T10:00:00Z"
  }
}

Response Codes

StatusMeaning
201 CreatedPolicy created; the full record is returned
400 Bad RequestValidation error: missing/invalid field (e.g. unknown effect, operation, or subject_type)
401 UnauthorizedAuthentication required or invalid
403 ForbiddenCaller lacks the policy-create permission or active membership
409 ConflictA policy with this name already exists in the organization
500 Internal Server ErrorUnexpected server error

Get Policy

GET /v1/rbac/policies/:id

Update Policy

PUT /v1/rbac/policies/:id

Delete Policy

DELETE /v1/rbac/policies/:id

Departments

List Departments

GET /v1/rbac/departments

Query Parameters

ParameterTypeDescription
searchstringFilter departments by name
limitintegerMax results (max: 100)
offsetintegerPagination offset

Create Department

POST /v1/rbac/departments

Request Body

{
  "name": "Engineering",
  "description": "Software engineering team",
  "parent_id": null
}

The organization is taken from the authenticated session. System-assigned fields (id, is_active, created_at, updated_at) are never supplied in the request body; they are assigned by the platform. When parent_id is provided it must reference an active department in the same organization.

Response

Returns the full created department record on success.

{
  "success": true,
  "data": {
    "id": "...",
    "org_id": "...",
    "parent_id": null,
    "name": "Engineering",
    "display_name": null,
    "description": "Software engineering team",
    "is_active": true,
    "created_at": "2026-04-13T10:00:00Z",
    "updated_at": "2026-04-13T10:00:00Z"
  }
}

Response Codes

StatusMeaning
201 CreatedDepartment created; the full record is returned
400 Bad RequestValidation error: missing/invalid name or malformed parent_id
401 UnauthorizedAuthentication required or invalid
403 ForbiddenCaller lacks the manage-departments permission or active membership
404 Not FoundThe supplied parent_id does not reference an active department in this organization
409 ConflictA department with this name already exists in the organization
500 Internal Server ErrorUnexpected server error

Update Department

PUT /v1/rbac/departments/:id

Delete Department

DELETE /v1/rbac/departments/:id

Groups

List Groups

GET /v1/rbac/groups

Query Parameters

ParameterTypeDescription
searchstringFilter groups by name
limitintegerMax results (max: 100)
offsetintegerPagination offset

Create Group

POST /v1/rbac/groups

Request Body

{
  "name": "Frontend Team",
  "slug": "frontend-team",
  "description": "Frontend engineering group",
  "role_ids": ["role-uuid-1", "role-uuid-2"]
}

The organization is taken from the authenticated session. System-assigned fields (id, is_active, created_at, updated_at) are never supplied in the request body; they are assigned by the platform.

Response

Returns the full created group record on success.

{
  "success": true,
  "data": {
    "id": "...",
    "org_id": "...",
    "name": "Frontend Team",
    "display_name": null,
    "description": "Frontend engineering group",
    "ai_instructions": null,
    "membership_rules": {},
    "linked_sso_group_id": null,
    "is_active": true,
    "created_at": "2026-04-13T10:00:00Z",
    "updated_at": "2026-04-13T10:00:00Z"
  }
}

Response Codes

StatusMeaning
201 CreatedGroup created; the full record is returned
400 Bad RequestValidation error: missing/invalid name
401 UnauthorizedAuthentication required or invalid
403 ForbiddenCaller lacks the manage-groups permission or active membership
409 ConflictA group with this name already exists in the organization
500 Internal Server ErrorUnexpected server error

Update Group

PUT /v1/rbac/groups/:id

Delete Group

DELETE /v1/rbac/groups/:id

Sync RBAC

POST /v1/rbac/sync

Trigger a full RBAC synchronization for the authenticated user’s organization. This recomputes all effective permissions based on current role assignments, group memberships, department hierarchy, and policy evaluations. Useful after bulk role or membership changes. The request body carries the synchronization configuration; the organization is resolved from the session. Admin or owner privileges are required.

Frequently Asked Questions

Are custom-role permissions least-privilege, or can I grant anything?
A custom role may only hold permissions you yourself hold. Requesting a permission you do not hold returns 403, so a custom role can never be used to escalate beyond your own access.

How do I know which permission slugs exist?
Call List Permissions. Permissions are a fixed system catalog; you reference existing slugs by their dot-notation key rather than inventing new ones. An unrecognized slug is rejected with 400 INVALID_INPUT.

Can I edit or delete the built-in roles?
No. Built-in roles are read-only, and a custom role may not reuse a built-in role’s key. Create a custom role instead.

Why does deleting a role fail?
A custom role can only be deleted when no members are assigned to it. Reassign its members to another role first. Built-in roles cannot be deleted at all.

Allow and deny policies conflict. Which wins?
Deny always wins. Effective permissions are computed from role assignments, group memberships, and department inheritance, then policy overrides are applied with deny taking precedence over allow.

Can I see another user’s effective permissions?
Yes, if you are an admin or owner. Pass user_id to GET /v1/rbac/effective-permissions; without admin or owner rights you can only query your own.