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:
- Every listed permission is required, not one of them. The attribute is an and, never an or.
- Dependencies are required too. A permission declares what it is meaningless without, and asking for it
asks for the whole chain.
AuditReadtherefore also requiresAuditList, andBatchProcessingImportpulls in fifteen others. The implied ones are shown in the tables so nothing has to be inferred. - 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
| Marker | Meaning |
|---|---|
| PermissionName | Named on the endpoint. The caller must hold it. |
| PermissionName | Not named, but required — pulled in as a dependency of one that is. |
| PermissionName | Checked inside the handler rather than by the attribute, because the answer depends on the request. |
| JWT only | Empty [RequiresPermissions]. A readable JWT and nothing more. |
| Anonymous | No attribute. Reachable without a token — see Anonymous endpoints. |
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
| Method | Path | Permissions | Notes |
|---|---|---|---|
GET | /organizations | OrganizationList | Global permission. |
GET | /organizations/{id} | OrganizationRead | Global. Returns each identity provider's client secret. |
GET | /organizations/{id}/signin | Anonymous | Name, branding and providers for the login page. Only active organizations answer. |
POST | /organizations/identityproviders/check | OrganizationRead OrganizationUpdate OrganizationCreate | Needs 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 | /organizations | OrganizationCreate | Global. |
PUT | /organizations/{id} | OrganizationUpdate | Global. |
DELETE | /organizations/{id} | OrganizationDelete | Global. |
Users
| Method | Path | Permissions | Notes |
|---|---|---|---|
GET | /users | UserList | Scoped to the caller's organization. |
GET | /users/names | AuditList | A 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/lookup | JWT only | Answers only about IDs the caller already has, so there is nothing to enumerate with. Scoped to the caller's organization. |
GET | /users/{id}/permissions | Anonymous | Called during session renewal, before there is a token to check. Reads only; creates and changes nothing. |
GET | /users/me | JWT only | The JWT decides which account is read. |
POST | /users/provision | Anonymous | The first call of every login. May create an account when the organization's patterns allow it. |
GET | /users/{id} | UserRead | |
POST | /users | UserCreate | |
PUT | /users/{id} | UserUpdate | |
DELETE | /users/{id} | UserDelete |
Groups
| Method | Path | Permissions | Notes |
|---|---|---|---|
GET | /groups | GroupList | |
GET | /groups/{id} | GroupRead | |
POST | /groups | GroupCreate grant check | Each permission put on the group must be one the caller may grant — see granting versus holding. |
PUT | /groups/{id} | GroupUpdate grant check | Same grant check on anything being added. Permissions the caller cannot grant are preserved rather than dropped. |
PUT | /groups/{id}/members | GroupUpdate | Membership only. |
DELETE | /groups/{id} | GroupDelete |
Permissions catalogue
| Method | Path | Permissions | Notes |
|---|---|---|---|
GET | /permissions | JWT only | Every signed-in user resolves their own permissions against this, so it asks for none of its own. |
API keys
| Method | Path | Permissions | Notes |
|---|---|---|---|
POST | /apikeys/lookup | Anonymous | Finds 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}/apikeys | UserRead | Hints and dates only. The hash is never returned. |
GET | /users/me/apikeys | JWT only | An account's own keys are its own business. |
POST | /users/me/apikeys | ProfileCreateApiKey ProfileNeverExpiresApiKey | ProfileNeverExpiresApiKey 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 only | Revoking your own credential is not a privilege someone grants you. Losing ProfileCreateApiKey does not strand your existing keys. |
DELETE | /users/{id}/apikeys/{apiKeyId} | UserDelete | Taking 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
| Method | Path | Permissions |
|---|---|---|
GET | /taggroups | TagGroupList |
GET | /taggroups/{id} | TagGroupRead |
POST | /taggroups | TagGroupCreate |
PUT | /taggroups/{id} | TagGroupUpdate |
DELETE | /taggroups/{id} | TagGroupDelete |
Tags
| Method | Path | Permissions |
|---|---|---|
GET | /tags | TagList |
GET | /tags/{id} | TagRead |
POST | /tags | TagCreate |
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.
| Method | Path | Permissions | Notes |
|---|---|---|---|
GET | /variablesets | VariableList | |
GET | /variablesets/{id} | VariableRead VariableReadSecrets | Returns the set's variables and their values. Sensitive values come back as hints unless the caller holds VariableReadSecrets. |
POST | /variablesets | VariableCreate | |
PUT | /variablesets/{id} | VariableUpdate | |
DELETE | /variablesets/{id} | VariableDelete |
Variables
| Method | Path | Permissions | Notes |
|---|---|---|---|
GET | /variables | VariableList VariableReadSecrets | |
GET | /variables/{id} | VariableRead VariableReadSecrets | |
POST | /variables | VariableCreate | |
PUT | /variables/{id} | VariableUpdate | |
DELETE | /variables/{id} | VariableDelete |
Variable values
| Method | Path | Permissions | Notes |
|---|---|---|---|
GET | /variablevalues | VariableRead VariableReadSecrets | The consumption endpoint. Without VariableReadSecrets, a sensitive value is replaced by its hint before resolution, so whichever value wins is already the hint. |
GET | /variablevalues/preview | VariableRead VariableReadSecrets | The same resolution, answering "what would a consumer get, and why". Withheld secrets come back as an empty value with the hint beside them. |
Import
| Method | Path | Permissions |
|---|---|---|
POST | /import | BatchProcessingImport |
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:
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.
| Method | Path | Permissions | Notes |
|---|---|---|---|
GET | / | AuditList AuditGlobalList | One page of entries. AuditGlobalList widens the page to include entries belonging to no organization — the logins and logouts written before a JWT exists. |
POST | /filter | AuditList AuditGlobalList | The same listing with a filter body. |
GET | /{id} | AuditRead AuditList AuditGlobalRead | AuditRead 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 | / | Anonymous | Every 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} | AuditDelete | Removes every version of the entry. Scoped to the caller's organization. |
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.
| Method | Path | Permissions | Notes |
|---|---|---|---|
POST | /generatejwt | Anonymous | Mints a session token from a provider identity. Answers 403 when the account is not permitted. |
POST | /refreshjwt | Anonymous | Renews a token that is still valid. |
POST | /apikeyjwt | Anonymous | Exchanges 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} | Anonymous | Generates a signing key pair. An operational tool, not part of the request path. |
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:
| Service | Method | Path | Why it cannot require a token |
|---|---|---|---|
| Account | GET | /organizations/{id}/signin | The login page renders before anyone is signed in. |
| Account | POST | /users/provision | The first call of a login; it decides whether the login continues at all. |
| Account | GET | /users/{id}/permissions | Called during session renewal, when the old token may already be gone. |
| Account | POST | /apikeys/lookup | Resolves a key to an account so a token can be minted from it. |
| Identity | POST | /generatejwt | Issues the token. |
| Identity | POST | /refreshjwt | Reissues the token. |
| Identity | POST | /apikeyjwt | Issues the token, authenticated by the key header instead. |
| Identity | GET | /generatejwk/{key} | Key generation, run out of band. |
| Audit | POST | / | 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:
| Held | May grant |
|---|---|
| PermissionsAllGlobal | Anything, global permissions included. |
| PermissionsAll | Anything that is not marked global. |
| Any other permission | That 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.
| Permission | Also requires |
|---|---|
AuditRead | AuditList |
AuditGlobalRead | AuditGlobalList |
VariableReadSecrets | VariableRead |
ProfileNeverExpiresApiKey | ProfileCreateApiKey |
BatchProcessingImport | The 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
{
"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
- Permissions model — how permissions reach a caller in the first place
- Permissions catalogue — every permission with its description, value and scope
- Managing access — putting permissions on groups and groups on people
- API overview — conventions shared by all four services