Appearance
Permission Model — API Analysis (API-A)
Concept layer — frozen. The Permission Model generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.
Feature: Permission Model
Paths below carry no version or gateway prefix: how a project versions and mounts these routes is an application decision, not part of the concept. Success envelope: { data, status, message, requestId }; errors carry a stable machine-readable code alongside the human-readable message.
Every endpoint on this page is administrative, and each is itself protected by a capability code — the management surface is inside the model it manages, never outside it. A project should give mutation of a globally-reaching set its own code, distinct from mutation of a scoped one, so that "can administer this scope" cannot be escalated into "can administer everywhere".
Stating the scope. Endpoints that create or revoke an assignment need to know where it applies. The concept assumes the scope travels the same way it travels on every other scoped request in the system — a header established by the tenant-scoping capability — and that the header, when present, overrides any scope in the body rather than being merged with it.
What a request that states no scope means is a decision, and the two answers are not equally safe.
- Scope-less means everywhere. The request is asking to act across all scopes and requires the capability as a genuinely global grant. Simple to state and to audit; the cost is that every route which is legitimately scope-agnostic — administrative reads that concern no scope's data — needs a global grant to reach, which pushes administrators toward holding global grants for everything.
- Scope-less falls back to "anywhere". The request is allowed if the actor holds the capability as a global grant or in any one scope. Keeps scope-agnostic routes reachable with ordinary scoped grants; the cost is that an administrator of one scope now reaches every route that permits the fallback. That is safe only if the set of routes permitting it is closed and reviewed, and the routes that manage global resources — sets, assignments, anything whose data spans scopes — are excluded from it by rule rather than by memory.
A project states which it chose and, under the second, where the exclusion list lives and what puts a route on it. Every endpoint on this page manages a global resource and therefore requires the global grant under either answer.
Endpoint index: the git host renders an outline from the
##headings below — there is no hand-maintained endpoint list to fall out of date.
1. Read the capability catalog
Endpoint: GET /permission-catalog
Description
Returns every capability code the system defines, with its grouping and its translation keys, so the administration UI can render a filterable picker with readable titles and descriptions.
Request Parameters
None. The catalog is small, fixed for the lifetime of the process, and filtered client-side.
Request Logic
No database read. The catalog is composed in memory from the metadata each protected handler declares, merged with an explicit list of capabilities that belong to no handler. This is what makes the catalog incapable of drifting from what exists — and equally what makes it unmodifiable at runtime.
Success Response (200 OK)
json
{
"data": {
"permissions": [
{
"permissionCode": "documents.document.read",
"module": "documents",
"titleKey": "permission.documents.document.read.title",
"descriptionKey": "permission.documents.document.read.description"
}
]
},
"status": 200,
"message": "Fetched permission catalog.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}A project may add fields describing where each code came from — the method and route it protects, the handler's operation identifier, which client surfaces use it. Those help an administrator understand a code, and they cost nothing because the catalog already knows them.
2. List permission sets
Endpoint: GET /permission-sets
Description
Paginated listing for browsing and searching sets. Returns metadata only — the rules belong to the detail read, because a list of sets with all their rules inlined is large and nobody reads it.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page / pageSize | Integer | No | Pagination, with a documented maximum page size |
name | String | No | Case-insensitive substring match |
active | Boolean | No | Filter by active flag |
reach | Enum | No | Filter by whether the set is scoped or global |
sortBy / sortDir | Enum | No | Sort column and direction, from a closed list |
Request Logic
Reads live sets, applies the optional filters, and paginates. Whether the listing is filtered by an owning scope depends on the scope-binding decision — see the feature page, Binding an assignment to a scope. Where sets are not owned by a scope, there is no ownership predicate to apply and the listing is inherently global.
Success Response (200 OK)
json
{
"data": {
"permissionSets": [
{
"id": "019bc8a1-2a3b-7a11-9a21-1b2c3d4e5f66",
"name": "Finance approvers",
"description": "Approve and post invoices.",
"reach": "scoped",
"active": true
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1
},
"status": 200,
"message": "Fetched permission sets.",
"requestId": "3ab34d88-65d1-4c10-897a-237c9a5b116f"
}3. Create a permission set
Endpoint: POST /permission-sets
Description
Creates a set together with its rules in one call. The rules are part of the set, so there is no separate "add rule" endpoint to leave a set half-built.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | String | Yes | Unique among live sets, within whatever the project scopes uniqueness to |
description | String | No | Free text |
reach | Enum | No | Scoped or global; defaults to scoped |
active | Boolean | No | Defaults to true |
rules | Array | No | Each { permissionCode, effect, breadth }; defaults to empty |
json
{
"name": "Finance approvers",
"reach": "scoped",
"active": true,
"rules": [
{ "permissionCode": "documents.document.approve", "effect": "allow", "breadth": "all" },
{ "permissionCode": "documents.document.delete", "effect": "deny", "breadth": "all" }
]
}Request Logic
- Every
permissionCodemust exist in the catalog; an unknown code is rejected rather than stored for later. effectandbreadthmust be legal values, and a breadth the project has not implemented is rejected, never widened.- Two rules with the same code and breadth are a duplicate and are refused — collapsing them silently would hide a contradiction the author meant to see.
- Creating a set with global reach requires the caller to hold the create capability as a global grant.
Success Response (201 Created)
json
{
"data": { "permissionSetId": "019bc8a1-2a3b-7a11-9a21-1b2c3d4e5f66" },
"status": 201,
"message": "Created permission set.",
"requestId": "16ddc487-1092-496e-b9a4-97d3e3082ee6"
}Error Responses
| Status | When |
|---|---|
400 | Unknown code, illegal or unimplemented effect/breadth, duplicate rule |
403 | Caller lacks the capability, or lacks it globally while creating a global set |
409 | The name is already held by a live set |
4. Read one permission set
Endpoint: GET /permission-sets/{permissionSetId}
Description
Full detail including the rules, for the administration detail screen.
Success Response (200 OK)
json
{
"data": {
"permissionSet": {
"id": "019bc8a1-2a3b-7a11-9a21-1b2c3d4e5f66",
"name": "Finance approvers",
"reach": "scoped",
"active": true,
"rules": [
{ "permissionCode": "documents.document.approve", "effect": "allow", "breadth": "all" }
]
}
},
"status": 200,
"message": "Fetched permission set.",
"requestId": "ad9cd51f-d993-4014-a2b2-8f563fe887fd"
}A soft-deleted set is not returned — it is gone as far as every reader is concerned, and its row exists only so that history keeps resolving.
5. Update a permission set
Endpoint: PATCH /permission-sets/{permissionSetId}
Description
Updates metadata, rules, or both. Only supplied fields change.
Request Logic
- Rules validate exactly as on create.
- Supplying
rulesreplaces the whole list. It is a set of rules, not a log of them, and a merge semantics would leave no way to remove one. - Widening a set's reach to global requires the caller to hold the update capability globally.
- On commit, the decided-capability cache is invalidated for every actor holding this set — directly or through a group. Changing rules changes access as surely as changing an assignment does, and this is the invalidation trigger most often left out.
Success Response (200 OK)
json
{
"data": { "permissionSetId": "019bc8a1-2a3b-7a11-9a21-1b2c3d4e5f66" },
"status": 200,
"message": "Updated permission set.",
"requestId": "d048a7e7-5230-4e11-fc79-acc36abaf31d"
}6. Delete a permission set
Endpoint: DELETE /permission-sets/{permissionSetId}
Description
Soft-deletes the set. Assignments referencing it become ineffective, because evaluation already discards deleted and inactive sets — so there is no cascade to write, and no risk of an assignment outliving its set into a broken state.
Request Logic
Marks the set deleted and invalidates the decided-capability cache for everyone who held it. Assignment rows are deliberately left in place: they are the record that the grant existed.
Success Response (204 No Content)
No body.
7. Assign a permission set
Endpoint: POST /permission-set-assignments
Description
Grants a set to an assignee — a person, or a group whose members inherit it — in one scope, or everywhere.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
permissionSetId | UUID | Yes | The set to grant; must exist and be live |
assigneeType | Enum | Yes | Person or group |
assigneeId | UUID | Yes | Must exist |
scopeId | UUID | No | Where it applies; absent means everywhere, and requires a global grant |
One endpoint or two. The domain model holds two relations — assignment to a person, assignment to a group — because they are read along different paths. A project may mirror that split in the API, giving each its own route and its own capability code, or keep one route discriminated by
assigneeType. Two routes make the capabilities separable, which is usually what an administrator wants; one route keeps the client simpler. State which, and keep it consistent with the delete.
Request Logic
- The assignment carries no effect. It is a bare link; denial lives in the set's rules. See the feature page, Deny lives in one layer only.
- Uniqueness is enforced on the triple of assignee, scope and set, so re-granting is a conflict rather than a silent duplicate.
- Under the nullable-column binding, granting a set in several scopes is several calls, and there is no object relating them to one another. Under a join-table binding, it is one assignment with several scope rows.
- On commit, the decided-capability cache is invalidated: for a person, theirs; for a group, every member's.
Success Response (201 Created)
json
{
"data": { "assignmentId": "9a1e0000-1111-2222-3333-444444444444" },
"status": 201,
"message": "Created assignment.",
"requestId": "b3e3d2a1-0efb-4e74-8c2f-7a6a3c4d5e6f"
}Error Responses
| Status | When |
|---|---|
403 | Caller lacks the capability, or asks for an everywhere-grant without holding it globally |
404 | Set or assignee does not exist |
409 | This assignee already holds this set in this scope |
8. Revoke an assignment
Endpoint: DELETE /permission-set-assignments/{assignmentId}
Description
Withdraws the grant. Soft delete, so the history of who once held what survives.
Request Logic
- Scoped to the caller's stated scope where one is given, so an administrator of one scope cannot revoke a grant in another by guessing an identifier.
- Invalidates the decided-capability cache on the same terms as the create.
Success Response (204 No Content)
No body.
There is no update. An assignment has nothing mutable: the set, the assignee and the scope are jointly its identity. Moving a grant to a different scope is a revoke and a new grant, which is also the honest description of what happens to access.
9. List assignments
Endpoint: GET /permission-set-assignments?scopeId=$scopeId
Description
Who holds what, in one scope or across all of them. Person and group assignments are returned as two lists, paginated independently — they are browsed separately and a single interleaved list would page badly.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
scopeId | UUID | Conditional | The scope to list; required unless global is set |
global | Boolean | No | List the everywhere-grants instead |
page / pageSize | Integer | No | Independent pagination per list |
Requiring one of scopeId or global — rather than defaulting to "everything" — is deliberate: an unscoped default here quietly returns the whole system's grants to anyone who forgets a parameter.
Success Response (200 OK)
json
{
"data": {
"people": { "items": [], "page": 1, "pageSize": 20, "totalCount": 0 },
"groups": { "items": [], "page": 1, "pageSize": 20, "totalCount": 0 }
},
"status": 200,
"message": "Fetched assignments.",
"requestId": "cb6b6f9a-1f0f-4a2b-9a8f-3d2e1c0b9a87"
}10. Read an actor's decided capabilities
Endpoint: GET /actors/{actorId}/effective-permissions?scopeId=$scopeId
Description
The evaluated result for one actor in one scope: a list of capability codes and whether each is allowed. This is what a client is given so it can hide what the actor cannot use.
Request Logic
Reads the decided-capability cache when warm, and otherwise runs the full evaluation and populates it.
In normal operation a client does not call this. The decided capabilities are bundled into whatever response already establishes the session, so no screen renders before it knows what to render. The endpoint exists for administration — inspecting another actor's access — and for recovering a client whose bundled copy is stale.
Success Response (200 OK)
json
{
"data": {
"actorId": "11111111-aaaa-bbbb-cccc-222222222222",
"scopeId": "11111111-2222-3333-4444-555555555555",
"decisions": [
{ "permissionCode": "documents.document.approve", "allowed": true }
]
},
"status": 200,
"message": "Computed effective permissions.",
"requestId": "7b0a1a41-fd2a-4bd9-9c2c-0f57db3e3f15"
}Note what is returned: decisions, not rules. Returning rules would oblige every client to re-implement precedence, and that is where the two sides drift apart.
11. Evaluate one capability, with an explanation
Endpoint: POST /permission-evaluations
Description
The diagnostic. Answers one question — may this actor do this, here? — and, when asked, says which rule decided it and which set that rule came from.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
actorId | UUID | Yes | Whose access is in question |
scopeId | UUID | Yes | The scope to evaluate in |
permissionCode | String | Yes | Must exist in the catalog |
explain | Boolean | No | Return the winning rule and its source set |
resource | Object | No | The record in hand, required only for record-level breadth |
Request Logic
Runs the evaluation described on the feature page: gather applicable assignments, collect the rules naming the code from live active sets, order them by the project's precedence rule, default to refusal. The endpoint reads through no cache — a diagnostic that answers from a cache cannot diagnose a stale cache.
Success Response (200 OK)
json
{
"data": {
"decision": { "allowed": true },
"explanation": {
"winningRule": {
"permissionSetId": "019bc8a1-2a3b-7a11-9a21-1b2c3d4e5f66",
"permissionSetName": "Finance approvers",
"rule": {
"permissionCode": "documents.document.approve",
"effect": "allow",
"breadth": "all"
}
}
}
},
"status": 200,
"message": "Authorization decision evaluated.",
"requestId": "dc8071c9-859e-5bcd-1bb4-7f71ad2a104d"
}The explanation is worth more than the decision. An administrator who can see which rule won learns the precedence order from the system rather than from a document, and the surprising case — a narrow allow beating a broad deny, or not — stops being an argument.
Error Responses
| Status | When |
|---|---|
400 | Unknown code, missing scope, or a resource context the project does not implement |
403 | Caller lacks the capability to inspect another actor's access |