Skip to main content

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,
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.
globalPermission (omitted, defaults false)Not a global permission — see scope below.
librarySetScopable: trueMay be restricted to specific library sets — see Library-set scoping below.
dependsOn: 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 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.

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.
GlobalOrganizationCertificateManageGlobalOrganizationUpdateReissuing 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.
ProfileNeverExpiresApiKeyProfileCreateApiKeyA decision about keys is meaningless if you cannot create one.
UserCreateServiceAccountApiKeyUserReadThe key is made from the service account's own page, which means being able to read the account.
BatchProcessingImportBatchProcessingView, plus 15 tag group, tag and variable permissionsAn import creates all three, and replacing deletes all three.
BatchProcessingExportBatchProcessingView, plus 6 list/read permissionsAn 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
PermissionsAllGlobalGrants anything, anywhere
PermissionsImpersonateUserUser impersonation
AuditGlobalList / AuditGlobalReadEntries belonging to no organization
GlobalOrganizationList / GlobalOrganizationRead / GlobalOrganizationCreate / GlobalOrganizationUpdate / GlobalOrganizationDeleteOrganization administration
GlobalOrganizationCertificateManageReissuing 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 holdsMay grant
PermissionsAllGlobalAnything, global permissions included
PermissionsAllAnything not marked global
NeitherNothing — 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:

  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 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:

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
AdministratorsUserList…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​