Skip to content
Updated Sep 12, 2026 by Barča Dvořáková · Owner: analysisactiveconceptgenericapi-a Edit on GitHub

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 ​

ParameterTypeRequiredDescription
page / pageSizeIntegerNoPagination, with a documented maximum page size
nameStringNoCase-insensitive substring match
activeBooleanNoFilter by active flag
reachEnumNoFilter by whether the set is scoped or global
sortBy / sortDirEnumNoSort 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 ​

FieldTypeRequiredDescription
nameStringYesUnique among live sets, within whatever the project scopes uniqueness to
descriptionStringNoFree text
reachEnumNoScoped or global; defaults to scoped
activeBooleanNoDefaults to true
rulesArrayNoEach { 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 permissionCode must exist in the catalog; an unknown code is rejected rather than stored for later.
  • effect and breadth must 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 ​

StatusWhen
400Unknown code, illegal or unimplemented effect/breadth, duplicate rule
403Caller lacks the capability, or lacks it globally while creating a global set
409The 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 rules replaces 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 ​

FieldTypeRequiredDescription
permissionSetIdUUIDYesThe set to grant; must exist and be live
assigneeTypeEnumYesPerson or group
assigneeIdUUIDYesMust exist
scopeIdUUIDNoWhere 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 ​

StatusWhen
403Caller lacks the capability, or asks for an everywhere-grant without holding it globally
404Set or assignee does not exist
409This 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 ​

ParameterTypeRequiredDescription
scopeIdUUIDConditionalThe scope to list; required unless global is set
globalBooleanNoList the everywhere-grants instead
page / pageSizeIntegerNoIndependent 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 ​

FieldTypeRequiredDescription
actorIdUUIDYesWhose access is in question
scopeIdUUIDYesThe scope to evaluate in
permissionCodeStringYesMust exist in the catalog
explainBooleanNoReturn the winning rule and its source set
resourceObjectNoThe 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 ​

StatusWhen
400Unknown code, missing scope, or a resource context the project does not implement
403Caller lacks the capability to inspect another actor's access