System and Custom Roles
For a product team building an administration console where a tenant's administrators see the roles of an app, create their own, copy one, and tick permissions. This page shows where each kind of role lives, how a request is granted through it, and which control-plane call each console action is. It continues An app without spaces.
Two kinds of role
| System role | Custom role | |
|---|---|---|
| Where | The app document, roles | A tenant document, roles |
| Written by | The platform, with the app | The tenant's administrators |
| Applies to | Every tenant | That tenant only |
| App | The document's | app, required: a custom role belongs to one app |
| Permissions | A list, or "*" for the catalog | A list only: "*" would let a catalog publish widen every tenant's role unseen |
| Can be empty | Yes | Yes |
A member holds a role by its id in its token or MembershipProvider, in either case. The id is the same string everywhere, so prefer opaque ids for custom roles (a name can be renamed in the console, an id cannot).
The documents
The app document adds a role without permissions to the one of the previous page; a console shows it as a role to be set up:
{
"schema": 2,
"revision": 1,
"publishedAt": "2026-11-01T08:00:00Z",
"catalog": {
"groups": [
{
"name": "Warehouse",
"permissions": [
{
"id": "warehouse.read",
"name": "Read warehouses",
"action": "wms:warehouse.read"
},
{
"id": "warehouse.edit",
"name": "Edit warehouses",
"action": "wms:warehouse.edit"
},
{
"id": "inbound.ship",
"name": "Ship inbound orders",
"action": "wms:inbound.ship",
"resource": "inbound/{id}"
}
]
}
]
},
"roles": [
{
"id": "clerk",
"name": "Clerk",
"description": "Looks things up",
"permissions": [
"warehouse.read"
]
},
{
"id": "manager",
"name": "Manager",
"permissions": [
"warehouse.read",
"warehouse.edit",
"inbound.ship"
]
},
{
"id": "admin",
"name": "Administrator",
"permissions": "*"
},
{
"id": "draft",
"name": "To be set up",
"permissions": []
}
]
}The tenant acme has two custom roles:
{
"schema": 2,
"revision": 1,
"publishedAt": "2026-11-01T08:00:00Z",
"roles": [
{
"id": "night-shift",
"app": "wms",
"name": "Night shift",
"description": "Watches the dock",
"permissions": [
"warehouse.read"
]
},
{
"id": "dispatcher",
"app": "wms",
"name": "Dispatcher",
"permissions": [
"warehouse.read",
"inbound.ship"
]
}
]
}Both are written as roles, not as bindings: CoSec derives one binding per permission when it reads the document, so the console's "permissions x of y" is the length of the role's permissions, and saving a role is one entry, not one binding per permission.
Check it
The scenarios in wiki/examples/roles run on every build:
| Token | Request | Result |
|---|---|---|
acme, role night-shift | read a warehouse | Permit |
acme, role night-shift | edit a warehouse | Deny: the role lists warehouse.read only |
globex, role night-shift | read a warehouse | Deny: night-shift is a role of acme's document |
globex, system role manager | edit a warehouse | Permit: a system role applies to every tenant |
acme, role draft | read a warehouse | Deny: a role without permissions grants nothing |
acme, custom role dispatcher | ship inbound order 42 | Permit |
The console flow
Every console action is one change on the tenant document, proposed against the revision the console showed. The calls are on the control API; a tenant's principals reach their own tenant document only.
| Console action | Operation |
|---|---|
| Create a role | One putRole with permissions: [] |
| Save as | One putRole with a new id and the copied list |
| Save the permission matrix | One putRole that replaces the role, optionally with ifMatch |
| Delete a role | One removeRole |
The bodies of the first three:
{
"document": "tenant:acme",
"base": 1,
"note": "New role: Receiver",
"operations": [
{
"op": "putRole",
"role": {
"id": "receiver",
"app": "wms",
"name": "Receiver",
"permissions": []
}
}
]
}{
"document": "tenant:acme",
"base": 2,
"note": "Save night-shift as night-shift-2",
"operations": [
{
"op": "putRole",
"role": {
"id": "night-shift-2",
"app": "wms",
"name": "Night shift (copy)",
"description": "Watches the dock",
"permissions": [
"warehouse.read"
]
}
}
]
}{
"document": "tenant:acme",
"base": 3,
"note": "Night shift may ship",
"operations": [
{
"op": "putRole",
"role": {
"id": "night-shift",
"app": "wms",
"name": "Night shift",
"description": "Watches the dock",
"permissions": [
"warehouse.read",
"inbound.ship"
]
}
}
]
}putRole replaces the whole role, so a replay is harmless. ifMatch, the SHA-256 of the role entry as the document writes it, makes the save fail with 412 if someone else changed the role first. A base that is no longer the document's revision is a 409: reload and try again.
A save is then three calls, with $CONTROL the control API's base (https://host/cosec/control/v1) and a token of the tenant's administrator, issued for the control plane's app (Change a document later):
ID=$(curl -s -X POST "$CONTROL/changes" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d @save-permissions.json | jq -r .id)
curl -s -X POST "$CONTROL/changes/$ID:skip-shadow" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"reason":"role matrix saved in the console"}'
curl -s -X POST "$CONTROL/changes/$ID:publish" -H "Authorization: Bearer $TOKEN"- Propose. The control plane validates and compiles the change, and analyzes what it grants: a permission the administrator does not hold is an escalation, and
NOT_COVEREDnames that permission. The change lists itsfindingsandescalations, so the console can show them next to the matrix. - Skip the window.
cosec:tenant-adminmay skip the shadow window on its own tenant's document, which a console save should: a window waits for traffic to compare. Skipping changes nothing about approval. - Publish. Without escalations the change publishes. With one, it waits for an approver (
cosec:approve), who is a platform principal unless you delegate it (Approval).
What an administrator may grant follows from what they hold (What a grantor needs): a permission editor who is allowed to write roles and grant in the tenant's scopes never needs an approver, while a space administrator is held to their space. A tenant owner holds everything through an attachment: A tenant owner.
The catalog and the system roles live in the app document, which a tenant's principal cannot read through the control API, so the console's backend lists them with a platform principal for now.
Next: roles in spaces, in Apps with spaces.