Skip to main content

API permissions

Every endpoint in TagShape, and what a caller must hold to reach it.

Enforcement is a single piece of middleware — TagShape.Common.Middleware.PermissionsMiddleware — reading the [RequiresPermissions] attribute off the endpoint's metadata. A caller whose JWT is missing, unreadable, or short of any required permission is refused with 403 Forbidden and the handler never runs. The refusal does not name what was missing; only the server log does.

Three rules govern the tables below, and they are worth having in mind before reading them:

  1. Every listed permission is required, not one of them. The attribute is an and, never an or.
  2. Dependencies are required too. A permission declares what it is meaningless without, and asking for it asks for the whole chain. AuditRead therefore also requires AuditList, and BatchProcessingImport pulls in sixteen others. The implied ones are shown in the tables so nothing has to be inferred.
  3. An empty [RequiresPermissions] still requires a readable JWT. It asks for no named permission, which is not the same as asking for nothing. Endpoints with no attribute at all are genuinely anonymous.

Legend​

MarkerMeaning
PermissionNameNamed on the endpoint. The caller must hold it.
PermissionNameNot named, but required — pulled in as a dependency of one that is.
PermissionNameChecked inside the handler rather than by the attribute, because the answer depends on the request.
JWT onlyEmpty [RequiresPermissions]. A readable JWT and nothing more.
AnonymousNo attribute. Reachable without a token — see Anonymous endpoints.
Where the permission names come from

The catalogue is the TagShape.Common.Authentication.Permission enum, served at runtime by GET /permissions on the account API. The management UI reads it from there rather than keeping a copy, and so should anything else that needs it. See the permissions catalogue for the full list with descriptions, numeric values and scope.

Account API​

Base path is the root of the api-account service.

Organizations​

MethodPathPermissionsNotes
GET/organizationsGlobalOrganizationListGlobal permission.
GET/organizations/{id}GlobalOrganizationReadGlobal. Returns each identity provider's client secret.
GET/organizations/{id}/signinAnonymousName, branding and providers for the login page. Only active organizations answer.
POST/organizations/identityproviders/checkGlobalOrganizationRead GlobalOrganizationUpdate GlobalOrganizationCreateNeeds GlobalOrganizationRead and either GlobalOrganizationUpdate or GlobalOrganizationCreate. The either/or cannot be expressed in the attribute, so the handler settles it and returns its own 403.
POST/organizationsGlobalOrganizationCreateGlobal.
PUT/organizations/{id}GlobalOrganizationUpdateGlobal.
DELETE/organizations/{id}GlobalOrganizationDeleteGlobal.

Users​

MethodPathPermissionsNotes
GET/usersUserListScoped to the caller's organization.
GET/users/namesAuditListA directory of names and emails. Asks for AuditList rather than UserList because it exists to put names to the IDs on audit entries.
POST/users/names/lookupJWT onlyAnswers only about IDs the caller already has, so there is nothing to enumerate with. Scoped to the caller's organization.
GET/users/{id}/permissionsAnonymousCalled during session renewal, before there is a token to check. Reads only; creates and changes nothing.
GET/users/meJWT onlyThe JWT decides which account is read.
POST/users/provisionAnonymousThe first call of every login. May create an account when the organization's patterns allow it.
GET/users/{id}UserRead
POST/usersUserCreate
PUT/users/{id}UserUpdate
PUT/users/{id}/groupsUserUpdateReplaces this user's group membership. The mirror of PUT /groups/{id}/members, from the other side of the same relationship.
DELETE/users/{id}UserDelete

Groups​

Managing a group uses the same permissions as managing a user — there is no permission family of its own for groups.

MethodPathPermissionsNotes
GET/groupsUserList
GET/groups/{id}UserRead
POST/groupsUserCreate grant checkEach permission put on the group must be one the caller may grant — see granting versus holding.
PUT/groups/{id}UserUpdate grant checkSame grant check on anything being added. Permissions the caller cannot grant are preserved rather than dropped.
PUT/groups/{id}/membersUserUpdateMembership only.
DELETE/groups/{id}UserDelete

Permissions catalogue​

MethodPathPermissionsNotes
GET/permissionsJWT onlyEvery signed-in user resolves their own permissions against this, so it asks for none of its own.

API keys​

MethodPathPermissionsNotes
POST/apikeys/lookupAnonymousFinds the account behind a key hash, for the identity API to mint a token from. Asked before the caller has a token.
GET/users/{id}/apikeysUserReadHints and dates only. The hash is never returned.
GET/users/me/apikeysJWT onlyAn account's own keys are its own business.
POST/users/me/apikeysProfileCreateApiKey ProfileNeverExpiresApiKeyProfileNeverExpiresApiKey is required only when expiry is NoExpiry. Which permission the request needs depends on its body, and the attribute is read before the body is — so this one is checked in the handler and refused as a 400, not a 403.
POST/users/{id}/apikeysUserCreateServiceAccountApiKey ProfileNeverExpiresApiKeyCreates a key for a service account in the caller's organization. UserCreateServiceAccountApiKey depends on UserRead, so both are required. Answers 400 if the account is a person — their key is theirs to make — and 404 if it is in another organization. ProfileNeverExpiresApiKey is checked against the caller, and only when expiry is NoExpiry.
DELETE/users/me/apikeys/{id}JWT onlyRevoking your own credential is not a privilege someone grants you. Losing ProfileCreateApiKey does not strand your existing keys.
DELETE/users/{id}/apikeys/{apiKeyId}UserDeleteTaking a working credential off someone else's account is the same kind of act as removing the account.

Library set API​

Base path is the root of the api-libraryset service. Every route is scoped to the organization in the caller's JWT; an ID belonging to another organization reads as not found.

Tag groups​

MethodPathPermissions
GET/taggroupsTagGroupList
GET/taggroups/{id}TagGroupRead
POST/taggroupsTagGroupCreate
PUT/taggroups/{id}TagGroupUpdate
DELETE/taggroups/{id}TagGroupDelete

Tags​

MethodPathPermissions
GET/tagsTagList
GET/tags/{id}TagRead
POST/tagsTagCreate
PUT/tags/{id}TagUpdate
DELETE/tags/{id}TagDelete

Library sets​

Called variable sets in the code and library sets in the UI and import file. One thing, two names.

Every Variable* permission below is library-set scopable: a caller whose grant is restricted to specific library sets is refused with 403 when a request names one outside it, and a list endpoint filters to the sets in scope instead. Creating a set always needs VariableCreate held unrestricted, whatever the parent — see the notes column.

MethodPathPermissionsNotes
GET/variablesetsVariableListFiltered to the library sets in the caller's scope, when it has one.
GET/variablesets/{id}VariableRead VariableReadSecretsReturns the set's variables and their values. Sensitive values come back as hints unless the caller holds VariableReadSecrets for this set. 403 if id is outside the caller's VariableRead scope. Costs 1 credit.
POST/variablesetsVariableCreateRequires VariableCreate held unrestricted - a scoped grant only reaches creating a variable inside an existing set, never a new set, root or nested.
PUT/variablesets/{id}VariableUpdate403 if id is outside the caller's VariableUpdate scope.
DELETE/variablesets/{id}VariableDelete403 if id is outside the caller's VariableDelete scope.

Variables​

MethodPathPermissionsNotes
GET/variablesVariableList VariableReadSecretsFiltered to variables in the caller's VariableList scope; each one's secrets are shown or hinted by VariableReadSecrets for its own library set.
GET/variables/{id}VariableRead VariableReadSecrets403 if the variable's library set is outside the caller's VariableRead scope. Costs 1 credit.
POST/variablesVariableCreate403 if the target library set (variableSetId in the request) is outside the caller's VariableCreate scope.
PUT/variables/{id}VariableUpdate403 if the variable's library set is outside the caller's VariableUpdate scope.
DELETE/variables/{id}VariableDelete403 if the variable's library set is outside the caller's VariableDelete scope.

Variable values​

Resolving a value can pull in one from an ancestor library set the caller never named — see library sets and variables — so both endpoints below require VariableRead (and, when relevant, VariableReadSecrets) for every library set actually reached, not only the ones named in set. Any one of them outside scope refuses the whole request with 403.

MethodPathPermissionsNotes
GET/variablevaluesVariableRead VariableReadSecretsThe consumption endpoint. Without VariableReadSecrets for every set reached, a sensitive value is replaced by its hint before resolution, so whichever value wins is already the hint. Costs 10 credits.
GET/variablevalues/previewVariableRead VariableReadSecretsThe same resolution, answering "what would a consumer get, and why". Withheld secrets come back as an empty value with the hint beside them. Costs 10 credits.

Credits​

GET /variables/{id}, GET /variablesets/{id}, GET /variablevalues, GET /variablevalues/preview and GET /export are billed against the organization's credits, after the permission check — a 401 or 403 is never charged. A call the organization cannot afford is refused with 429 Too Many Requests. Only 2xx responses are charged. Every other library set API endpoint, including the list endpoints, is free.

Batch processing​

Import and export both sit under BatchProcessingView, which controls whether the management UI's Batch section is shown at all — see the permissions catalogue.

Import​

MethodPathPermissions
POST/importBatchProcessingImport

BatchProcessingImport names one permission and stands for seventeen. An import creates tag groups, tags, library sets and variables, and a replacing import (?replaceExisting=true) deletes all of them — so the permission declares a dependency on everything it touches, and the middleware requires the whole closure:

BatchProcessingImportBatchProcessingViewTagGroupListTagGroupReadTagGroupCreateTagGroupUpdateTagGroupDeleteTagListTagReadTagCreateTagUpdateTagDeleteVariableListVariableReadVariableCreateVariableUpdateVariableDelete

VariableReadSecrets is deliberately not among them. An import writes values, sensitive ones included, and is never shown one back.

Export​

MethodPathPermissions
GET/exportBatchProcessingExport VariableReadSecrets

Costs 1 credit per library set exported; the request is refused with 429 if the organization cannot cover every one.

BatchProcessingExport names one permission and stands for eight. Unlike import, an export only ever lists and reads what it writes to a file — so it depends on the list and read permission of each of the three kinds it touches, and none of the create/update/delete permissions import depends on:

BatchProcessingExportBatchProcessingViewTagGroupListTagGroupReadTagListTagReadVariableListVariableRead

VariableReadSecrets is conditional rather than implied: it is required only when the request asks for secretExportOption=Value, checked in the handler against the request rather than declared as a blanket dependency. Every other secret export option (Hint, Hash, Blank) needs nothing beyond BatchProcessingExport itself.

Audit API​

Base path is the root of the api-audit service.

MethodPathPermissionsNotes
GET/AuditList AuditGlobalListOne page of entries. AuditGlobalList widens the page to include entries belonging to no organization — the logins and logouts written before a JWT exists.
POST/filterAuditList AuditGlobalListThe same listing with a filter body.
GET/{id}AuditRead AuditList AuditGlobalReadAuditRead depends on AuditList, so both are required. The before and after state of a global entry needs AuditGlobalRead instead of AuditRead; without it the entry is returned with its state withheld.
POST/AnonymousEvery service writes here, including login and logout flows that run before a JWT exists. It carries its own rule instead: without a JWT, the entryType must appear in the service's Audit:AuthNotRequired allow-list, or the request is refused with 401.
DELETE/{id}AuditDeleteRemoves every version of the entry. Scoped to the caller's organization.
Two axes, not one

Listing and reading are separate, and so are organizational and global. AuditList gets you the list; AuditRead gets you an entry's before and after state. AuditGlobalList and AuditGlobalRead are the same split applied to entries that belong to no organization. A single page can therefore show an entry and withhold what it changed.

Identity API​

Base path is the root of the api-identity service. No endpoint here enforces a permission, because every one of them exists to establish an identity that does not yet exist. This service is intended to be reachable only from inside the cluster.

MethodPathPermissionsNotes
POST/generatejwtAnonymousMints a session token from a provider identity. Answers 403 when the account is not permitted.
POST/refreshjwtAnonymousRenews a token that is still valid.
POST/apikeyjwtAnonymousExchanges an API key for a JWT. The key is presented in the x-tagshape-api header and the token comes back in x-tagshape-auth. A missing key and a bad key get the same 401.
GET/generatejwk/{key}AnonymousGenerates a signing key pair. An operational tool, not part of the request path.
Network boundary, not permissions

Four endpoints on the identity API, plus POST /apikeys/lookup, GET /users/{id}/permissions, POST /users/provision and GET /organizations/{id}/signin on the account API, are anonymous by necessity — requiring a token would fail the very requests that exist to issue one. Their protection is that neither service is exposed outside the cluster. GET /organizations/{id}/signin is the weakest of them: it needs only an organization ID and returns each provider's client secret. Encrypting those at rest is a known outstanding item.

Anonymous endpoints​

The complete list, in one place:

ServiceMethodPathWhy it cannot require a token
AccountGET/organizations/{id}/signinThe login page renders before anyone is signed in.
AccountPOST/users/provisionThe first call of a login; it decides whether the login continues at all.
AccountGET/users/{id}/permissionsCalled during session renewal, when the old token may already be gone.
AccountPOST/apikeys/lookupResolves a key to an account so a token can be minted from it.
IdentityPOST/generatejwtIssues the token.
IdentityPOST/refreshjwtReissues the token.
IdentityPOST/apikeyjwtIssues the token, authenticated by the key header instead.
IdentityGET/generatejwk/{key}Key generation, run out of band.
AuditPOST/Writes login and logout entries that happen before a token exists. Guarded by its own allow-list.

Granting versus holding​

Two different questions, with two different answers.

Holding a permission is what lets you use it, and it is checked with HasPermissions. The catch-all permissions do not stand in here: someone with PermissionsAll still only does what they have actually been given.

Granting a permission — putting it on a group — is checked with CanGrant, and the catch-alls are not just what it is for, they are the only thing that decides it:

HeldMay grant
PermissionsAllGlobalAnything, global permissions included.
PermissionsAllAnything that is not marked global.
NeitherNothing — not even a permission held outright.

The distinction is deliberate. Granting is delegation, not use — an administrator decides who reads secrets without that making them a reader of secrets, and holding a permission outright is not a way to hand it to somebody else. When a group is saved, permissions the caller cannot grant are rejected on the way in and preserved on the way out, so an administrator with narrow rights cannot strip a group of something they were never able to give it.

PermissionsImpersonateUser is a third catch-all, marked global, covering user impersonation.

Permission dependencies​

Seven permissions depend on others. The middleware follows the chain as far as it goes, so holding the named one is never enough on its own.

PermissionAlso requires
AuditReadAuditList
AuditGlobalReadAuditGlobalList
VariableReadSecretsVariableRead
ProfileNeverExpiresApiKeyProfileCreateApiKey
UserCreateServiceAccountApiKeyUserRead
BatchProcessingImportBatchProcessingView, plus the fifteen tag group, tag and variable permissions listed above
BatchProcessingExportBatchProcessingView, plus the six list/read permissions listed above

The reasoning is the same in each case: the dependent permission says nothing on its own. Reading a secret means nothing without being able to read the variable holding it. A key that never expires is a decision about keys, which is meaningless if you cannot create one.

What a refusal looks like​

403 Forbidden
{
"title": "Forbidden",
"detail": "You do not hold the permissions required for this request.",
"status": 403
}

The response deliberately does not say which permission was missing — that would tell a prober what to go looking for. The server log does, naming the method, path, caller, the full required set with dependencies expanded, and what the caller actually held.

The one exception is the API key lifetime check, which names ProfileNeverExpiresApiKey in a 400 response. It can afford to: the caller demonstrably does not hold it, so there is nothing to give away, and knowing is the only way they can go and ask for it.

See also​