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 fifteen 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/organizationsOrganizationListGlobal permission.
GET/organizations/{id}OrganizationReadGlobal. 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/checkOrganizationRead OrganizationUpdate OrganizationCreateNeeds OrganizationRead and either OrganizationUpdate or OrganizationCreate. The either/or cannot be expressed in the attribute, so the handler settles it and returns its own 403.
POST/organizationsOrganizationCreateGlobal.
PUT/organizations/{id}OrganizationUpdateGlobal.
DELETE/organizations/{id}OrganizationDeleteGlobal.

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
DELETE/users/{id}UserDelete

Groups

MethodPathPermissionsNotes
GET/groupsGroupList
GET/groups/{id}GroupRead
POST/groupsGroupCreate grant checkEach permission put on the group must be one the caller may grant — see granting versus holding.
PUT/groups/{id}GroupUpdate grant checkSame grant check on anything being added. Permissions the caller cannot grant are preserved rather than dropped.
PUT/groups/{id}/membersGroupUpdateMembership only.
DELETE/groups/{id}GroupDelete

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

MethodPathPermissionsNotes
GET/variablesetsVariableList
GET/variablesets/{id}VariableRead VariableReadSecretsReturns the set's variables and their values. Sensitive values come back as hints unless the caller holds VariableReadSecrets.
POST/variablesetsVariableCreate
PUT/variablesets/{id}VariableUpdate
DELETE/variablesets/{id}VariableDelete

Variables

MethodPathPermissionsNotes
GET/variablesVariableList VariableReadSecrets
GET/variables/{id}VariableRead VariableReadSecrets
POST/variablesVariableCreate
PUT/variables/{id}VariableUpdate
DELETE/variables/{id}VariableDelete

Variable values

MethodPathPermissionsNotes
GET/variablevaluesVariableRead VariableReadSecretsThe consumption endpoint. Without VariableReadSecrets, a sensitive value is replaced by its hint before resolution, so whichever value wins is already the hint.
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.

Import

MethodPathPermissions
POST/importBatchProcessingImport

BatchProcessingImport names one permission and stands for sixteen. 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:

BatchProcessingImportTagGroupListTagGroupReadTagGroupCreateTagGroupUpdateTagGroupDeleteTagListTagReadTagCreateTagUpdateTagDeleteVariableListVariableReadVariableCreateVariableUpdateVariableDelete

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

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 that is what the catch-alls are for:

HeldMay grant
PermissionsAllGlobalAnything, global permissions included.
PermissionsAllAnything that is not marked global.
Any other permissionThat permission, and nothing else.

The distinction is deliberate. Granting is delegation, not use — an administrator decides who reads secrets without that making them a reader of secrets. 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.

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

Permission dependencies

Five 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
BatchProcessingImportThe fifteen tag group, tag and variable 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