Appearance
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 indomain/constants/system-roles.ts) - Seeded by migration:
SeedSystemOwnerRole1774067070563inserts the role, itsmanage:allpolicy, and backfills existing owners - Auto-assigned on registration: both
RegisterUserAgencyOwnerCommandandRegisterUserBusinessOwnerCommandsetroleId: SYSTEM_OWNER_ROLE_IDon theOrganizationMember - Cannot be modified or deleted: protected by
isSystemchecks 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:
| Goal | Action | Subject |
|---|---|---|
| Full access to everything | manage | all |
| Read-only on agents | read | Agent |
| Full CRUD on chats | manage | Chat |
| Manage knowledge docs | manage | Knowledge |
| View models | read | Model |
| Manage roles themselves | manage | Role |
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.
| Subject | Action | Description | Default roles |
|---|---|---|---|
ai.api-key | create | Save a new provider credential to the org vault | Owner, Admin |
ai.api-key | read | List or view BYOK keys (plaintext credentials are never returned) | Owner, Admin |
ai.api-key | update | Rename a saved key (credential rotation is delete-then-create) | Owner, Admin |
ai.api-key | delete | Remove a saved key (must be unbound from every agent first) | Owner, Admin |
ai.api-key | bind | Bind a saved key to an agent's brain configuration | Owner, Admin |
ai.api-key | unbind | Remove an agent's BYOK binding | Owner, 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). Ifnull, 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: Whentrue, 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
- Request: The frontend sends a request with an
x-org-idheader. - Guard: The
PoliciesGuardextracts theuserIdfrom the token andorgIdfrom the header. - Org Scope Validation: Any endpoint using
@OrgId()now triggers the globalOrgAccessGuard. It rejects missingx-org-id, validates that the target organization belongs touser.agencyId, and verifies that the user is a member of that org before the controller runs. - Policy Guard Validation: When the route also uses
@CheckPolicies(), the same org/agency validation is repeated throughIdentityPermissionsProviderbefore loading permissions. - Role Policies: If the member has roles, all
Policyrecords attached to those roles are fetched. - User Policies: Direct
UserPolicyentries foruserId + orgIdare fetched and appended (these extend/override role policies). - Interpolation: The
CaslAbilityFactoryreplaces placeholders like${user.id}and${tenant.orgId}with actual runtime values. - Assertion: The application layer uses
AuthorizationService.assertCanto verify the action against the resource.
Security
- Cross-Agency Isolation:
OrgAccessGuardandIdentityPermissionsProviderreject anyx-org-idthat 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 === falsebefore 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:
AuthorizationServicestrips__caslSubjectType__from user-provided objects.
API Endpoints
Roles
| Method | Endpoint | Description | Policy Check |
|---|---|---|---|
| GET | /roles | List agency + system roles | can('read', 'Role') |
| GET | /roles/:id | Get role with its policies | can('read', 'Role') |
| POST | /roles | Create a new role | can('create', 'Role') |
| PUT | /roles/:id | Update a role | can('update', 'Role') |
| DELETE | /roles/:id | Delete a role + its policies | can('delete', 'Role') |
| POST | /roles/:id/policies | Attach a policy to a role | can('manage', 'Role') |
| DELETE | /roles/policies/:id | Detach a policy from a role | can('manage', 'Role') |
User Policies
| Method | Endpoint | Description | Policy Check |
|---|---|---|---|
| GET | /user-policies?userId= | List user policies for the org in x-org-id | can('read', 'UserPolicy') |
| POST | /user-policies | Attach one or more direct user policies | can('manage', 'UserPolicy') |
| DELETE | /user-policies/:id | Remove a direct user policy | can('manage', 'UserPolicy') |
Organization members
| Method | Endpoint | Description | Policy Check |
|---|---|---|---|
| PUT | /api/organization/members/:userId/role | Assign role to member (creates membership if missing) | can('manage', 'Role') |
| DELETE | /api/organization/members/:userId | Remove member from org + org-scoped user policies | can('manage', 'Role') |
Examples
Assign "Chat Viewer" role
- Create a role:
POST /roleswith{ "name": "Chat Viewer" } - Attach read policy:
POST /roles/:id/policieswith{ "action": "read", "subject": "Chat" } - Assign to member:
PUT /api/organization/members/:userId/rolewith{ "roleId": "..." }andx-org-id
Deny delete on Agents
- Attach manage policy:
{ "action": "manage", "subject": "Agent" } - Attach deny policy:
{ "action": "delete", "subject": "Agent", "inverted": true } - Result: user can create/read/update Agents but not delete them.
Direct user override
- User has "Chat Viewer" role (only
read Chat) - Attach direct user policies:
{ "policyIds": ["<knowledge-manage-policy-id>"] } - Result: user can read chats AND manage knowledge docs.