Scopes¶
Every endpoint behind an OAuth2-protected route requires one or more
scopes. The platform's scope catalogue uses a flat
<resource>:<action> shape — cases:read, patients:write,
images:read, derm_review:write, and so on. The full live catalogue
is rendered on the admin Integration tab for any given
(organisation, product).
Three forms a granted scope can take¶
When an operator provisions your OAuth2 client they hand-pick the scopes you're allowed to ask for. The grant can be expressed in three ways:
| Form | Example | What it covers |
|---|---|---|
| Exact | cases:read |
Just that one scope |
| Prefix wildcard | cases:* |
Every cases:<action> scope, present and future |
| Global wildcard | * |
Every scope in the catalogue |
The matching is dynamic — when the platform adds a new
cases:archive scope, an integration granted cases:* picks it up
automatically. No re-issuing of credentials needed.
The match logic only applies in the granted slot. Required scopes
(the ones declared on platform endpoints) are always concrete: a
wildcard in your @RequireScopes(...)-equivalent decorator (if you
were building one) would be a footgun.
When to ask for a wildcard grant¶
| Ask for | When |
|---|---|
Exact list (cases:read, cases:write, images:read) |
You're doing the "request only what you need" thing carefully and want operators to ratify every new permission |
Prefix (cases:*, images:*) |
Your integration is the system of record for that resource — adding new actions should Just Work |
Global (*) |
Internal system clients only. Don't request this for third-party integrations |
There is no functional difference between being granted
['cases:read', 'cases:write'] and ['cases:*'] today — the
difference is whether the next quarterly cases:<new-action> scope
flows to you automatically or needs an operator action.
How scopes appear in tokens¶
The access token's scopes claim carries the grant verbatim — so
exact lists stay exact, wildcards stay as wildcards. Resource servers
expand wildcards at request time via the shared
scopeMatches(granted, required) helper from @sa-platform/common.
For tokens minted via the token-exchange grant (clinician
delegation), the picture is different: the issued delegated token
carries the concrete intersection of (consumer's granted scopes,
user's role permissions). That way downstream audit logs attribute
the action to a precise capability, not a wildcard category. A
consumer holding cases:* exchanging on behalf of a clinician whose
role grants cases:read and cases:write gets a delegated token
with scopes: ['cases:read', 'cases:write'].
Requesting a subset in a token request¶
The scope= parameter on POST /v1/auth/oauth/token is a downscope — it
narrows the scopes baked into the issued token, never widens them.
Requested scopes must be concrete; wildcards in scope= return
invalid_scope. Example:
POST https://platform.example.com/v1/auth/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=<id>
&client_secret=<secret>
&scope=cases:read patients:read
If you ask for cases:read and your client has cases:*, you get a
token carrying just cases:read — useful for short-lived tokens
issued to less-trusted callers within your stack.
Baseline catalogue¶
The platform seeds ~25 baseline permissions on every user-management boot (idempotent via upsert-by-permission-string). The current set:
| Service | Permissions |
|---|---|
| platform | admin:read, admin:write, platform:admin |
| clinical-api | cases:{read,write}, patients:{read,write}, images:{read,write}, histology:{read,write}, medications:{read,write}, events:read, webhooks:{read,write}, derm_review:{read,write} |
| orchestrator | orchestrator:{read,write,publish,intervene}, question_sets:{read,write,publish} |
Operators can add more by hitting POST /v1/user-management/permissions/register
on user-management — the baseline only exists so the admin console's
Create-Role page has something to populate the multi-select with on a
fresh deployment.