RBAC
Role-based access control decides what each principal is allowed to do. Permission keys define the actions that exist; roles group keys together; assignments map a principal to roles. The same model governs humans, AI agents, and service accounts.
RBAC is the authority layer. It answers "is this principal allowed?" The enforcement layer is what makes the answer impossible to bypass.
How it works
RBAC in RootCX is global. A single role can span multiple apps, tools, and integrations. 4 concepts:
- Permission keys define what actions exist. Declared in manifests, auto-generated on deploy.
- Roles group permission keys together. Created at runtime via the API. Support inheritance.
- Assignments map a principal to one or more roles.
- Resolution computes a principal's effective permissions: the union of every key from every assigned role, including inherited roles.
The same resolution logic runs in two places (as Rust in the Core and as PL/pgSQL inside the RLS policies), so an HTTP check and a row-level check always agree. See Enforcement.
Permission key format
Keys use a namespace:scope:action convention:
| Namespace | Example | Source |
|---|---|---|
app: |
app:crm:contacts.read |
App entity CRUD, auto-generated on deploy |
app: |
app:crm:action:pipeline |
Auto-created for declared app actions |
app: |
app:crm:invoke |
Auto-created to gate invoking the app's agent |
app: |
app:crm:cron.trigger |
Auto-created for the app's scheduled jobs |
tool: |
tool:query_data |
Built-in agent tool permissions |
integration: |
integration:gmail:send_email |
Auto-created for integration actions on deploy |
storage: |
storage:read, storage:invoices:read |
Platform storage (global or per-bucket) |
platform: |
platform:apps.create |
Self-service: create and own apps |
admin: |
admin:apps.deploy, admin:secrets.manage |
Platform administration |
* |
(global admin) | Matches everything |
App permission keys are declared as entity.action in the manifest and auto-prefixed with app:{appId}: on deploy. The Core always generates the 4 CRUD keys for every entity; any permissions you declare explicitly are added on top, never a replacement.
The admin: namespace gates platform operations such as deploying apps, managing secrets, reading the audit log, querying the database, and managing agents, integrations, and service accounts. Global admins (*) satisfy these implicitly.
Wildcard matching
Permission keys support wildcards with the :* suffix. The matching algorithm:
*matches everything (global admin).prefix:*matches any key that starts withprefix:, with the colon boundary enforced.- Otherwise, an exact string match.
| Pattern | Matches | Does not match |
|---|---|---|
* |
Everything | |
app:crm:* |
app:crm:contacts.read, app:crm:deals.create |
app:support:tickets.read |
tool:* |
tool:query_data, tool:mutate_data |
app:crm:contacts.read |
integration:gmail:* |
integration:gmail:send_email |
integration:slack:send |
The colon boundary is enforced: app:crm:* does not match app:crm_extended:something.
Actions and HTTP methods
Each entity permission key maps to one operation on the Data API:
| Action suffix | HTTP | Description |
|---|---|---|
.create |
POST | Create a new record |
.read |
GET | List or get records |
.update |
PATCH | Modify an existing record |
.delete |
DELETE | Remove a record |
For declared actions (in the actions array of the manifest), the Core auto-creates keys in the format app:{appId}:action:{actionId}. You do not declare these in the permissions block.
Built-in roles
2 roles are seeded on every Core:
admin: permissions["*"](global admin). Cannot be deleted, and its permissions and inherits cannot be modified. The first registered user is automatically assigned this role.base: no permissions. The deny-by-default role for federated and invited users. It is the safedefault_rolefor OIDC; real access comes from explicitly granted roles.
2 more roles are generated automatically, not seeded:
app:{appId}:admin: carriesapp:{appId}:*. Assigned to whoever installs an app, giving them full control of that app without platform-level authority.app:{appId}:agent: a least-privilege role derived from the app's data contract. Assigned to the app's agent on first deploy. See AI Agent Governance.
Role inheritance
Roles can declare an inherits array pointing to other roles. The Core walks the full inheritance chain, merging all permission keys transitively. Maximum depth: 64 levels.
Cycles are detected and rejected on create and update (depth-first cycle detection). Diamond inheritance is supported: a role can inherit two roles that both inherit a common parent, and the common parent is resolved once.
Example: editor inherits from viewer. A user with the editor role gets the permissions of both editor and viewer.
Managing roles
Create a role
POST /api/v1/roles
Admin only.
{
"name": "editor",
"description": "Can read and write contacts",
"inherits": ["viewer"],
"permissions": ["app:crm:contacts.create", "app:crm:contacts.update"]
}
The name must not be empty and cannot be admin (reserved). Every permission key is validated against the [a-z0-9_:.*] charset. If inherits would create a cycle, the request is rejected with 400.
Update a role
PATCH /api/v1/roles/{roleName}
Admin only. All fields optional:
{
"description": "Updated description",
"inherits": ["viewer", "commenter"],
"permissions": ["app:crm:contacts.create", "app:crm:contacts.update", "app:crm:contacts.delete"]
}
You cannot modify the permissions or inherits of the admin role. Changing a role's permissions or inherits invalidates the affected principals' running workers, so the new authority takes effect on the next action.
Delete a role
DELETE /api/v1/roles/{roleName}
Admin only. Cannot delete the admin role. Deleting a role also removes every assignment for it.
List roles
GET /api/v1/roles
Returns all roles with their description, inherits, and permissions arrays.
[
{
"name": "editor",
"description": "Can read and write contacts",
"inherits": ["viewer"],
"permissions": ["app:crm:contacts.create", "app:crm:contacts.update"]
}
]
Assignments
Assign a role
POST /api/v1/roles/assign
Admin only.
{ "userId": "3f7a1b2c-...", "role": "editor" }
The role must exist. Duplicate assignments are silently ignored. The target can be any principal: a human, an agent, or a service account.
Revoke a role
POST /api/v1/roles/revoke
Admin only.
{ "userId": "3f7a1b2c-...", "role": "editor" }
Cannot revoke the last admin assignment (prevents lockout); returns 400. Returns 404 if the assignment does not exist.
List assignments
GET /api/v1/roles/assignments
Admin only. Returns every principal-to-role mapping:
[
{ "userId": "3f7a1b2c-...", "role": "editor", "assignedAt": "2024-12-01T10:00:00Z" }
]
Checking permissions
Current principal
GET /api/v1/permissions
Authenticated. Returns the caller's effective permissions, with every role expanded and all inherited keys merged:
{
"roles": ["editor", "viewer"],
"permissions": ["app:crm:contacts.create", "app:crm:contacts.read", "app:crm:contacts.update"]
}
Specific principal
GET /api/v1/permissions/{userId}
Returns another principal's effective permissions. If the caller is not the target, admin is required.
Available permissions
GET /api/v1/permissions/available
Authenticated. Returns the declared permission keys the caller is allowed to grant. The catalog is filtered to keys the caller already holds, so a non-admin cannot hand out authority it lacks.
[
{ "key": "app:crm:contacts.create", "description": "create contacts" },
{ "key": "integration:gmail:send_email", "description": "Send Email via gmail" }
]
API endpoints summary
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/roles |
No | List all roles |
| POST | /api/v1/roles |
Admin | Create a role |
| PATCH | /api/v1/roles/{roleName} |
Admin | Update a role |
| DELETE | /api/v1/roles/{roleName} |
Admin | Delete a role |
| GET | /api/v1/roles/assignments |
Admin | List all assignments |
| POST | /api/v1/roles/assign |
Admin | Assign a role to a principal |
| POST | /api/v1/roles/revoke |
Admin | Revoke a role from a principal |
| GET | /api/v1/permissions |
Yes | Caller's effective permissions |
| GET | /api/v1/permissions/{userId} |
Yes (admin for others) | A principal's effective permissions |
| GET | /api/v1/permissions/available |
Yes | Grantable permission keys, filtered to the caller's authority |