Skip to content

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 roleCustom role
WhereThe app document, rolesA tenant document, roles
Written byThe platform, with the appThe tenant's administrators
Applies toEvery tenantThat tenant only
AppThe document'sapp, required: a custom role belongs to one app
PermissionsA list, or "*" for the catalogA list only: "*" would let a catalog publish widen every tenant's role unseen
Can be emptyYesYes

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:

json
{
  "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:

json
{
  "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:

TokenRequestResult
acme, role night-shiftread a warehousePermit
acme, role night-shiftedit a warehouseDeny: the role lists warehouse.read only
globex, role night-shiftread a warehouseDeny: night-shift is a role of acme's document
globex, system role manageredit a warehousePermit: a system role applies to every tenant
acme, role draftread a warehouseDeny: a role without permissions grants nothing
acme, custom role dispatchership inbound order 42Permit

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 actionOperation
Create a roleOne putRole with permissions: []
Save asOne putRole with a new id and the copied list
Save the permission matrixOne putRole that replaces the role, optionally with ifMatch
Delete a roleOne removeRole

The bodies of the first three:

json
{
  "document": "tenant:acme",
  "base": 1,
  "note": "New role: Receiver",
  "operations": [
    {
      "op": "putRole",
      "role": {
        "id": "receiver",
        "app": "wms",
        "name": "Receiver",
        "permissions": []
      }
    }
  ]
}
json
{
  "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"
        ]
      }
    }
  ]
}
json
{
  "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):

bash
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"
  1. 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_COVERED names that permission. The change lists its findings and escalations, so the console can show them next to the matrix.
  2. Skip the window. cosec:tenant-admin may 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.
  3. 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.

Licensed under the Apache License, Version 2.0.