Skip to content

RBAC and Custom Roles

The platform supports a dynamic Role-Based Access Control (RBAC) system that allows agencies to define custom roles and assign them to users within specific organizations (orgId). This system is powered by CASL.

System Owner Role (Bootstrap)

When a user registers as an agency owner or business owner, they are automatically assigned the "Organization Owner" system role. This role has a single policy: manage:all — the CASL superuser pattern that grants full access to every resource and action within the organization context.

  • Role ID: well-known UUID 019d1c5c-5682-70fc-bdff-3a496709dc59 (constant in domain/constants/system-roles.ts)
  • Seeded by migration: SeedSystemOwnerRole1774067070563 inserts the role, its manage:all policy, and backfills existing owners
  • Auto-assigned on registration: both RegisterUserAgencyOwnerCommand and RegisterUserBusinessOwnerCommand set roleId: SYSTEM_OWNER_ROLE_ID on the OrganizationMember
  • Cannot be modified or deleted: protected by isSystem checks in update/delete commands

This solves the bootstrap problem — without it, nobody would have permissions to create roles or manage anything.

Platform Admin Role (Platform Scope)

Users assigned the Platform Admin system role (SYSTEM_ROLE_PLATFORM_ADMIN_ID in domain/constants/system-roles.ts) receive platform-scoped policies (isPlatform: true in system-policies.ts). Among them, platform base plans use subject platform.plan with actions manage, read, create, update, and delete, so routes that call ability.can('create' | 'update' | 'delete', 'platform.plan') are authorized for that role after policies are seeded (POST /policies/seed and POST /roles/seed as applicable).

Getting Started: Setting Up Access for Your Organization

Once you register as an owner and your account is verified, you have full access (manage:all). Here's how to set up access for your team:

Step 1: Create custom roles

http
POST /roles
x-org-id: <your-org-id>

{ "name": "Agent Manager", "description": "Can manage AI agents and knowledge" }

Step 2: Attach policies to the role

http
POST /roles/:roleId/policies
x-org-id: <your-org-id>

{ "policyIds": ["<policy-id-1>", "<policy-id-2>"] }

The role attachment endpoint accepts one or more existing policy IDs in a single request. Common policy patterns:

GoalActionSubject
Full access to everythingmanageall
Read-only on agentsreadAgent
Full CRUD on chatsmanageChat
Manage knowledge docsmanageKnowledge
View modelsreadModel
Manage roles themselvesmanageRole

Available actions: read, create, update, delete, manage (wildcard for all actions).

Available subjects: Agent, Chat, Knowledge, Message, Model, Platform, Role, UserPolicy, User, all (wildcard for all subjects).

AI — API Keys (BYOK)

BYOK (Bring Your Own Key) introduces a dedicated claim family on the ai.api-key subject. These claims gate the org-scoped BYOK vault and the per-agent binding surface. See the BYOK feature doc for end-to-end behavior.

SubjectActionDescriptionDefault roles
ai.api-keycreateSave a new provider credential to the org vaultOwner, Admin
ai.api-keyreadList or view BYOK keys (plaintext credentials are never returned)Owner, Admin
ai.api-keyupdateRename a saved key (credential rotation is delete-then-create)Owner, Admin
ai.api-keydeleteRemove a saved key (must be unbound from every agent first)Owner, Admin
ai.api-keybindBind a saved key to an agent's brain configurationOwner, Admin
ai.api-keyunbindRemove an agent's BYOK bindingOwner, Admin

Platform-wide access to BYOK is covered by the existing platform.manage_all policy and does not require the ai.api-key.* claims directly.

Step 3: Assign the role to a team member

http
PUT /api/organization/members/:userId/role
x-org-id: <your-org-id>

{ "roleId": "<role-id>" }

Step 3b: Remove a member from the organization

This deletes the OrganizationMember row and all org-scoped UserPolicy rows for that userId + orgId. It does not delete the user account.

http
DELETE /api/organization/members/:userId
x-org-id: <your-org-id>

Removing a user who holds the Organization Owner system role is rejected when they are the last owner in that org (HTTP 409, IDENTITY.LAST_ORGANIZATION_OWNER_CANNOT_BE_REMOVED).

Step 4 (optional): Add direct user overrides

If a specific user needs extra permissions beyond their role, attach one or more existing policy IDs directly:

http
POST /user-policies
x-org-id: <your-org-id>

{ "userId": "<user-id>", "policyIds": ["<policy-id-1>", "<policy-id-2>"] }

This endpoint now supports bulk attachment, so a single request can apply several direct user overrides.

To create allow or deny rules, first create the underlying Policy records and then attach their IDs to the role or user.

For example, to deny a specific action even if the role grants it, first create a deny policy and then attach that policy ID:

http
POST /policies
x-org-id: <your-org-id>

{ "name": "Deny agent delete", "action": "delete", "subject": "Agent", "inverted": true }

Data Model

The authorization schema consists of three main entities:

1. Role

Represents a collection of permissions.

  • orgId: The organization that owns this role (agency or business). If null, it's a global system role.
  • name: Human-readable name (e.g., "Agent Manager", "Viewer").
  • isSystem: Indicates if it's a predefined platform role. System roles cannot be modified or deleted.

2. Policy

A specific CASL rule attached to a Role.

  • action: The action allowed (e.g., manage, read, update).
  • subject: The domain resource (e.g., Agent, Chat, User).
  • conditions: A JSONB object containing MongoDB-style conditions (e.g., { "orgId": "${tenant.orgId}" }).
  • inverted: When true, acts as a deny rule — explicitly forbids the action.

3. UserPolicy

Allows attaching a standalone permission directly to a user, bypassing roles.

  • userId: The target user.
  • orgId: The organization where this policy applies.
  • action, subject, conditions, inverted: Standard CASL rule fields.

Assignment

Users are assigned roles through the OrganizationMember entity, which includes a roleId linking to the Role table.

Evaluation Flow

  1. Request: The frontend sends a request with an x-org-id header.
  2. Guard: The PoliciesGuard extracts the userId from the token and orgId from the header.
  3. Org Scope Validation: Any endpoint using @OrgId() now triggers the global OrgAccessGuard. It rejects missing x-org-id, validates that the target organization belongs to user.agencyId, and verifies that the user is a member of that org before the controller runs.
  4. Policy Guard Validation: When the route also uses @CheckPolicies(), the same org/agency validation is repeated through IdentityPermissionsProvider before loading permissions.
  5. Role Policies: If the member has roles, all Policy records attached to those roles are fetched.
  6. User Policies: Direct UserPolicy entries for userId + orgId are fetched and appended (these extend/override role policies).
  7. Interpolation: The CaslAbilityFactory replaces placeholders like ${user.id} and ${tenant.orgId} with actual runtime values.
  8. Assertion: The application layer uses AuthorizationService.assertCan to verify the action against the resource.

Security

  • Cross-Agency Isolation: OrgAccessGuard and IdentityPermissionsProvider reject any x-org-id that points to an organization outside the agency carried in the JWT/session context.
  • Org Spoofing Prevention: The provider also validates that the user is actually a member of the claimed orgId. A non-member receives 403.
  • System Role Protection: All mutation commands (update, delete) check isSystem === false before proceeding.
  • Role Deletion Cascade: Deleting a role atomically removes all its policies in a database transaction.
  • Member Removal: Removing a member runs in a transaction: org-scoped user policies for that user are deleted, then the membership row. The last Organization Owner in an org cannot be removed.
  • Subject Type Injection: AuthorizationService strips __caslSubjectType__ from user-provided objects.

API Endpoints

Roles

MethodEndpointDescriptionPolicy Check
GET/rolesList agency + system rolescan('read', 'Role')
GET/roles/:idGet role with its policiescan('read', 'Role')
POST/rolesCreate a new rolecan('create', 'Role')
PUT/roles/:idUpdate a rolecan('update', 'Role')
DELETE/roles/:idDelete a role + its policiescan('delete', 'Role')
POST/roles/:id/policiesAttach a policy to a rolecan('manage', 'Role')
DELETE/roles/policies/:idDetach a policy from a rolecan('manage', 'Role')

User Policies

MethodEndpointDescriptionPolicy Check
GET/user-policies?userId=List user policies for the org in x-org-idcan('read', 'UserPolicy')
POST/user-policiesAttach one or more direct user policiescan('manage', 'UserPolicy')
DELETE/user-policies/:idRemove a direct user policycan('manage', 'UserPolicy')

Organization members

MethodEndpointDescriptionPolicy Check
PUT/api/organization/members/:userId/roleAssign role to member (creates membership if missing)can('manage', 'Role')
DELETE/api/organization/members/:userIdRemove member from org + org-scoped user policiescan('manage', 'Role')

Examples

Assign "Chat Viewer" role

  1. Create a role: POST /roles with { "name": "Chat Viewer" }
  2. Attach read policy: POST /roles/:id/policies with { "action": "read", "subject": "Chat" }
  3. Assign to member: PUT /api/organization/members/:userId/role with { "roleId": "..." } and x-org-id

Deny delete on Agents

  1. Attach manage policy: { "action": "manage", "subject": "Agent" }
  2. Attach deny policy: { "action": "delete", "subject": "Agent", "inverted": true }
  3. Result: user can create/read/update Agents but not delete them.

Direct user override

  1. User has "Chat Viewer" role (only read Chat)
  2. Attach direct user policies: { "policyIds": ["<knowledge-manage-policy-id>"] }
  3. Result: user can read chats AND manage knowledge docs.