Permissions model
Permissions are the whole of authorisation in TagShape. There are no roles and no ownership rules — a caller
either holds a permission or does not. The one exception is library sets: the six Variable* permissions may
each be restricted to specific library sets instead of applying to every one in the organisation — see
Library-set scoping below.
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", librarySetScopable: true, dependsOn: VariableRead)]
VariableReadSecrets = 0x0703,
| Part | Meaning |
|---|---|
0x0703 | The 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. |
globalPermission (omitted, defaults false) | Not a global permission — see scope below. |
librarySetScopable: true | May be restricted to specific library sets — see Library-set scoping below. |
dependsOn: VariableRead | What 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 seventeen.
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
Eight permissions declare a dependency, and the graph is followed as far as it goes.
| Permission | Depends on | Because |
|---|---|---|
AuditRead | AuditList | Reading an entry means nothing without being able to see the log. |
AuditGlobalRead | AuditGlobalList | Same, for entries belonging to no organization. |
VariableReadSecrets | VariableRead | A secret is read through the variable holding it. |
GlobalOrganizationCertificateManage | GlobalOrganizationUpdate | Reissuing a certificate is a more sensitive act than editing the rest of the record, so it is a permission of its own rather than folded into GlobalOrganizationUpdate — but it still needs it. |
ProfileNeverExpiresApiKey | ProfileCreateApiKey | A decision about keys is meaningless if you cannot create one. |
UserCreateServiceAccountApiKey | UserRead | The key is made from the service account's own page, which means being able to read the account. |
BatchProcessingImport | BatchProcessingView, plus 15 tag group, tag and variable permissions | An import creates all three, and replacing deletes all three. |
BatchProcessingExport | BatchProcessingView, plus 6 list/read permissions | An export only ever reads what it writes to a file. |
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 groups are:
| Permission | |
|---|---|
PermissionsAllGlobal | Grants anything, anywhere |
PermissionsImpersonateUser | User impersonation |
AuditGlobalList / AuditGlobalRead | Entries belonging to no organization |
GlobalOrganizationList / GlobalOrganizationRead / GlobalOrganizationCreate / GlobalOrganizationUpdate / GlobalOrganizationDelete | Organization administration |
GlobalOrganizationCertificateManage | Reissuing an organization's client certificates |
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.
Library-set scoping
Six permissions — VariableList, VariableRead, VariableReadSecrets, VariableCreate, VariableUpdate,
VariableDelete — may be granted for every library set in the organisation (the default, and the only option
before this existed) or restricted to specific ones. The catalogue exposes this as librarySetScopable on
each permission.
Unscoped is the default. A group's permission with no library sets named applies everywhere, exactly as it always has. Nothing about existing groups changes until somebody deliberately narrows one.
No permission is needed beyond what granting the permission already needs. Scoping is judged by the same
standing as granting outright — see Granting versus holding below. Whoever could
hand out VariableUpdate for every library set could always have handed it out for just one.
A permission scoped narrower than what it depends on is only ever as wide as the narrower of the two.
VariableReadSecrets scoped to set A while VariableRead is scoped to set B reaches neither — reading a
secret from A needs VariableRead there too, and it is not. The catalogue's dependency graph is what a scope
is checked against, the same graph dependencies above already describes.
Creating a library set itself is never scoped. VariableCreate restricted to specific sets still lets its
holder create a variable inside one of them, but never a new library set — root or nested under an in-scope
parent. Creating a set at all needs VariableCreate held for every library set.
Scopes do not cascade through the library-set hierarchy. A library set can nest under a parent, but a scope naming the parent does not reach its children — each set that should be covered has to be named.
Batch import and export need every relevant permission unrestricted. POST /import and GET /export each
touch an entire file's worth of library sets in one request, so a grant scoped to only some of them is refused
outright rather than silently applied to part of the file.
An endpoint enforces a scope with a resource ID it already has in hand — the library set being read, updated
or deleted — refusing with the same 403 shape as the blanket check when a request names a real permission
for a set outside the caller's grant. A list endpoint filters to the sets in scope instead of refusing.
Granting versus holding
The distinction that matters most, and the one most easily missed.
Holding — HasPermissions — 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.
Granting — CanGrant — is what lets you put a permission on a group. The catch-alls are not just the point
here, they are the only thing that decides it:
| Caller holds | May grant |
|---|---|
PermissionsAllGlobal | Anything, global permissions included |
PermissionsAll | Anything not marked global |
| Neither | Nothing — not even a permission held outright |
Granting is delegation, not use. An administrator decides who reads secrets without that making them a reader of secrets - and, the other way round, holding a permission outright is not a way to hand it to somebody else.
What happens on a group save
When a group is created or updated:
- Additions are checked. Any permission being added that the caller cannot grant is rejected, and the rejected ones are named in the response.
- Dependencies are checked. A group carrying
AuditReadwithoutAuditListis refused — the permission would do nothing, and a group that silently does nothing is worse than a rejected save. - 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 UserUpdate 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:
| Group | Permissions |
|---|---|
| Readers | TagGroupList, TagGroupRead, TagList, TagRead, VariableList, VariableRead |
| Editors | Readers, plus the Create/Update/Delete of each |
| Secret readers | VariableRead, VariableReadSecrets |
| Deployment | VariableList, VariableRead, VariableReadSecrets — for the accounts behind deployment API keys |
| Auditors | AuditList, AuditRead |
| Administrators | UserList…UserDelete (also governs groups), 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
- API permissions — every endpoint and what it asks for
- Permissions catalogue — the complete list with values and descriptions
- Managing access — doing this in practice