Skip to main content

Permissions model

Permissions are the whole of authorisation in TagShape. There are no roles, no resource-level ACLs and no ownership rules — a caller either holds a permission or does not.

How a permission reaches a caller

Permission ──▶ Group ──▶ User ──▶ JWT ──▶ Endpoint

Permissions are put on groups. Users are members of groups. At login (and at every refresh) the account API gathers the permissions of every group the user belongs to, and the identity API stamps the union onto the JWT as a flat array of integers.

Nobody holds a permission directly. To change what someone may do, change their groups or change the groups' permissions — and either way it takes effect at their next token refresh, not immediately.

Anatomy of a permission

Each is declared in the Permission enum with a [PermissionInfo] attribute:

[PermissionInfo("Variables", "Read variable secrets", false, VariableRead)]
VariableReadSecrets = 0x0703,
PartMeaning
0x0703The numeric value carried in the JWT. Grouped by category in the high byte.
"Variables"Category, used to group the list in the UI.
"Read variable secrets"Human description.
falseNot a global permission — see scope below.
VariableReadWhat this permission is meaningless without.

The catalogue is served at runtime by GET /permissions, so the management UI reads the categories, descriptions and dependency graph from there rather than keeping a copy. Anything else that needs the list should do the same.

Enforcement

One piece of middleware — PermissionsMiddleware — reads [RequiresPermissions] off the endpoint's metadata. This covers both minimal API handlers and routable components.

app.MapGet("/variables/{id}", [RequiresPermissions(Permission.VariableRead)] async (...) => { ... });

Three things follow from how the attribute is defined:

Everything listed is required. The attribute is an and, never an or. An either/or has to be settled inside the handler — POST /organizations/identityproviders/check is the one place that happens.

Dependencies are required too. The middleware expands the closure before checking, so an endpoint can name one permission and get everything it stands for. That is what lets POST /import name BatchProcessingImport and require sixteen.

An empty attribute still requires a readable JWT. [RequiresPermissions] with no arguments asks for no named permission — not for nothing. An endpoint with no attribute at all is genuinely anonymous.

A refusal is 403 with a body that does not name what was missing. The server log names everything: the method, the path, the caller, the full required set with dependencies expanded, and what the caller actually held.

Dependencies

Five permissions declare a dependency, and the graph is followed as far as it goes.

PermissionDepends onBecause
AuditReadAuditListReading an entry means nothing without being able to see the log.
AuditGlobalReadAuditGlobalListSame, for entries belonging to no organization.
VariableReadSecretsVariableReadA secret is read through the variable holding it.
ProfileNeverExpiresApiKeyProfileCreateApiKeyA decision about keys is meaningless if you cannot create one.
BatchProcessingImport15 tag group, tag and variable permissionsAn import creates all three, and replacing deletes all three.

Dependencies are binding, not advisory. Granting AuditRead alone gets somebody nothing at all.

Global versus organizational

A permission marked global reaches beyond the organization holding it. Six are:

Permission
PermissionsAllGlobalGrants anything, anywhere
PermissionsImpersonateUser impersonation
AuditGlobalList / AuditGlobalReadEntries belonging to no organization
OrganizationList / OrganizationRead / OrganizationCreate / OrganizationUpdate / OrganizationDeleteOrganization administration

The flag bears on who may hand the permission out, not on how it is checked. Holding a global permission is checked exactly like holding any other.

Granting versus holding

The distinction that matters most, and the one most easily missed.

HoldingHasPermissions — is what lets you use a permission. The catch-alls do not stand in here. Somebody with PermissionsAll administers every permission in the organization and still only does what they have actually been given.

GrantingCanGrant — is what lets you put a permission on a group. Here the catch-alls are the whole point:

Caller holdsMay grant
PermissionsAllGlobalAnything, global permissions included
PermissionsAllAnything not marked global
Anything elseExactly that permission

Granting is delegation, not use. An administrator decides who reads secrets without that making them a reader of secrets.

What happens on a group save

When a group is created or updated:

  1. Additions are checked. Any permission being added that the caller cannot grant is rejected, and the rejected ones are named in the response.
  2. Dependencies are checked. A group carrying AuditRead without AuditList is refused — the permission would do nothing, and a group that silently does nothing is worse than a rejected save.
  3. Ungrantable existing permissions are preserved. A permission already on the group that the caller cannot grant is kept rather than dropped, so an administrator with narrow rights cannot strip a group of something they were never able to give it.

Point 3 is what makes it safe to hand GroupUpdate to somebody without handing them everything the groups already carry.

Designing a permission set

A few groups that compose tend to work better than one group per person:

GroupPermissions
ReadersTagGroupList, TagGroupRead, TagList, TagRead, VariableList, VariableRead
EditorsReaders, plus the Create/Update/Delete of each
Secret readersVariableRead, VariableReadSecrets
DeploymentVariableList, VariableRead, VariableReadSecrets — for the accounts behind deployment API keys
AuditorsAuditList, AuditRead
AdministratorsUserListUserDelete, GroupListGroupDelete, PermissionsAll

Note that Secret readers is separable from Editors: writing a secret needs only VariableUpdate, so the people who maintain configuration need not be the people who can read the secrets in it.

See also