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,
| 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. |
false | Not a global permission — see scope below. |
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 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.
| 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. |
ProfileNeverExpiresApiKey | ProfileCreateApiKey | A decision about keys is meaningless if you cannot create one. |
BatchProcessingImport | 15 tag group, tag and variable permissions | An 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 | |
|---|---|
PermissionsAllGlobal | Grants anything, anywhere |
PermissionsImpersonate | User impersonation |
AuditGlobalList / AuditGlobalRead | Entries belonging to no organization |
OrganizationList / OrganizationRead / OrganizationCreate / OrganizationUpdate / OrganizationDelete | Organization 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.
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. Here the catch-alls are the whole point:
| Caller holds | May grant |
|---|---|
PermissionsAllGlobal | Anything, global permissions included |
PermissionsAll | Anything not marked global |
| Anything else | Exactly 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:
- 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 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:
| 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, GroupList…GroupDelete, 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