Roles From an External Source
For a team whose roles and permissions are owned by another system, such as an event-sourced identity system that issues the tokens, and that wants CoSec to enforce what that system says. CoSec's documents are then a projection of the source: they are written by a sync service, and only through the control plane. This page shows the calls, the principal that runs them and what to expect from conflicts.
What you need
| External source | |
|---|---|
| Source of truth | Your identity system: roles, the permissions of a role, and the app's permission list |
| A sync service | A consumer of the source's events that calls the control API |
| Its principal | A principal of the (platform) tenant holding cosec:approve, cosec:skip-shadow and cosec:publish: the trust root (cosec:platform-admin does) |
| Documents | The tenant documents hold roles; the app document holds the catalog |
| Tokens | The identity system issues them, with its role ids in roles |
Do not write Redis directly. A write to the cache skips validation, compilation and the audit record; whatever bypasses the control plane is not checked before it is enforced. The control plane is the only writer.
What the sync produces
A tenant document that is a projection of the source holds one role entry per role of the source, named by the source's id:
{
"schema": 2,
"revision": 1,
"publishedAt": "2026-11-01T08:00:00Z",
"roles": [
{
"id": "role-01HQ7Z",
"app": "wms",
"name": "Receiving",
"permissions": [
"warehouse.read",
"inbound.ship"
]
}
]
}Names and descriptions may be left out when the source owns them; id, app and permissions are what CoSec needs. The app document's catalog lists the permissions the source knows, as short-form entries (Role permissions).
Source change to control-plane call
| The source changes | Control-plane call |
|---|---|
| A role's permissions | putRole, without ifMatch |
| A role is deleted | removeRole |
| An app's permissions | putCatalog |
| Periodic reconciliation | apply with MERGE, or REPLACE when the source is the only writer of the document |
{
"document": "tenant:acme",
"base": 7,
"note": "role-01HQ7Z changed in the identity system (event 5521)",
"operations": [
{
"op": "putRole",
"role": {
"id": "role-01HQ7Z",
"app": "wms",
"name": "Receiving",
"permissions": [
"warehouse.read",
"inbound.ship"
]
}
}
]
}{
"document": "tenant:acme",
"base": 8,
"note": "role-01HQ7Y deleted in the identity system (event 5530)",
"operations": [
{
"op": "removeRole",
"id": "role-01HQ7Y"
}
]
}{
"document": "app:wms",
"base": 4,
"note": "wms published its permissions (event 5544)",
"operations": [
{
"op": "putCatalog",
"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}"
}
]
}
]
}
}
]
}base is the revision the sync service last read for that document; note is for the audit trail, so name the source's event there. Each change goes the same three steps, which a sync service repeats for every event:
ID=$(curl -s -X POST "$CONTROL/changes" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d @role-changed.json | jq -r .id)
curl -s -X POST "$CONTROL/changes/$ID:skip-shadow" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"reason":"projection of event 5521"}'
curl -s -X POST "$CONTROL/changes/$ID:publish" -H "Authorization: Bearer $TOKEN"To reconcile, send the whole document the source implies to POST $CONTROL/documents/tenant:acme:apply with {"document": …, "mode": "MERGE", "note": …}; REPLACE also removes the roles the document leaves out.
How it behaves
- Who may grant is the source's decision. The sync service is the trust root, so CoSec does not second-guess what it projects, and records every change in the audit trail. With
cosec.control.approval.four-eyes: true, what the analysis cannot prove contained would wait for a second platform principal: leave it off for an automated projection. - Replays are safe.
putRolereplaces a role whole, so applying an event twice leaves the same document. - Conflicts are the projection's to retry. A change whose
baseis no longer the document's revision is a409; read the document, apply the pending events again on the new base, and propose again. - One writer per document. A change takes the document's staging slot until it publishes. If someone else holds it,
skip-shadowanswers409, and only a change that narrows may preempt. Do not let people and the sync service change the same document; people keep the tenant document of their custom roles, the source keeps the others. - Ordering. Process the source's events for one document in order. Different documents can be synced in parallel.
- Tokens decide when it applies. A role in a token takes effect when the user next logs in, whatever the document says. For a change that must apply at once, serve memberships from a
MembershipProvideror revoke the principal's tokens (Memberships).
Check it
The scenarios in wiki/examples/external-source run on every build, and the change bodies above are decoded as the control API decodes them. A token holding role-01HQ7Z ships an inbound order; a token holding the deleted role-01HQ7Y is granted nothing.
If it fails
403from the control API: the sync principal lacks one of the three actions, or its token is not issued for the control plane's app (Change a document later).422REFERENCESon a tenant document: a role lists a permission the app's catalog does not define. Publish the catalog change first.- A permission the app removes while a tenant role lists it: the publish is refused until the roles stop listing it (Role permissions).
Next: what a route map changes about the statements that apply, in Authorizing business actions.