Product documentation
Just-in-time privileged elevation (PIM)
Grant a subject a time-bound, step-up-gated elevation to a tenant:* scope: define an eligibility, activate it (with optional approval), and have the active scope reach the access token through the enrichment channel — then auto-expire. Grounded in the shipped product-api PIM surface and the hub scope-union customizer.
Just-in-time privileged elevation (PIM)
Privileged Identity Management (PIM) lets a tenant hand out standing intent to hold a
privileged scope, without leaving that privilege switched on. A subject who is eligible for a
tenant:* scope carries none of it day to day; when they need it they activate — proving a
step-up authentication and (optionally) passing an approval — and the scope becomes live only for
a bounded window, then expires on its own.
The model has three moving parts:
- Eligibility — a standing row saying "subject S may request scope X, for up to N seconds, with/without approval". Created by an admin. It grants nothing by itself.
- Activation — a time-bound request against an eligibility. Requires step-up. Ends up
ACTIVE(elevation live) orPENDING_APPROVAL(awaiting a reviewer), and always carries an expiry. - The token reach — an
ACTIVEactivation's scope is unioned into the access token'sscopeclaim the next time the subject mints a token, so a resource server's@PreAuthorize("hasAuthority('SCOPE_tenant:…')")actually passes. When the activation expires or is revoked, the next token no longer carries it.
Everything below is on the customer-plane management API (/api/v1/** on product-api, behind
the public api-gateway). The tenant is always taken from the tnt claim, never from the path;
request and response bodies are camelCase; errors are RFC 9457 problem-details
(application/problem+json) with a stable errorCode; lists are cursor-paginated; and
cross-tenant (or cross-mode) access returns 404, never 403 — the platform's
no-existence-leak invariant.
The lifecycle at a glance
admin (tenant:pim.admin) eligible subject (tenant:pim.elevate) reviewer (tenant:pim.approve)
───────────────────────── ───────────────────────────────────── ─────────────────────────────
POST /pim/eligible-assignments
subject may request scope X ───▶ POST /pim/activations (step-up ACR)
requireApproval=false ─▶ ACTIVE
requireApproval=true ─▶ PENDING_APPROVAL ─▶ POST /activations/{id}/approve ─▶ ACTIVE
│
next access-token mint: hub unions the ACTIVE tenant:* scope into `scope` ◀────────────────────┘
│
expiry (auto, ~60s sweep) or POST /activations/{id}/revoke ─▶ EXPIRED / REVOKED
│
next access-token mint no longer carries the scope
Prerequisites
- A tenant-admin access token with the right scope for each step (see the table below). Scopes
ride under the
tenant:namespace and are granted to your customer-plane client the same way as every other tenant scope; the PIM scopes were added to the grantable set in hub migrationV110__add_pim_entitlement_scopes_to_customer_plane_clients.sql. - For the token to actually carry an activated scope, the client the subject signs in through
must have the
pimclaims-in-token source enabled (see Configuration). - Step-up authentication configured, so an eligible subject can obtain an
acrofurn:thoryn:acr:step-up:highin their token (see Risk-based step-up).
Step 1 — Define an eligibility (admin)
Managing who may elevate is a higher privilege than elevating, so eligibility CRUD is gated on
its own scope, tenant:pim.admin — distinct from the tenant:pim.elevate /
tenant:pim.approve scopes the activation flow uses.
curl -sS -X POST https://api.stg.thoryn.org/api/v1/pim/eligible-assignments \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subjectId": "user-42",
"scope": "tenant:billing.write",
"maxTtlSeconds": 3600,
"requireApproval": true
}'subjectId(required) — the subject (sub) that may later request this elevation.scope(required) — the scope to make elevatable. Must be in thetenant:*namespace.maxTtlSeconds(optional, default3600) — the ceiling an activation's TTL is capped to.requireApproval(optional, defaulttrue) — whether an activation needs a reviewer before it goesACTIVE.
Two escalation guards run, in order, before the row is written — this is the "you can only
delegate what you hold" model (the same one the console OAuth-client scope grant uses, per
adrs/2026-07-01-rbac-authorization-model.md):
- Namespace floor. Only
tenant:*scopes are elevatable. Anadmin:*(operator-plane) scope or an OIDC building block (openid/profile/ …) can never be made elevatable. - Transitive hold. The admin may only grant eligibility for a scope they currently hold themselves. A subject can never become eligible for more than the admin holds.
Either guard failing returns 403 scope_not_grantable. A duplicate eligibility for the same
(subject, scope) in the same mode returns 409 eligibility_exists. On success you get 201 with
the eligibility record.
List, read, and revoke eligibilities on the same base path:
curl -sS https://api.stg.thoryn.org/api/v1/pim/eligible-assignments \
-H "Authorization: Bearer $ADMIN_TOKEN" # GET — cursor-paginated, newest first
curl -sS -X DELETE https://api.stg.thoryn.org/api/v1/pim/eligible-assignments/$ID \
-H "Authorization: Bearer $ADMIN_TOKEN" # DELETE — 204, or 404 if not in your tenantStep 2 — Request an activation (eligible subject)
The eligible subject requests elevation with tenant:pim.elevate. This step is where step-up
is enforced: the request token must carry acr = urn:thoryn:acr:step-up:high. A request
without it is rejected 403 pim_step_up_required before any eligibility lookup — step-up is a hard
precondition, not a soft signal.
curl -sS -X POST https://api.stg.thoryn.org/api/v1/pim/activations \
-H "Authorization: Bearer $STEPPED_UP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scope": "tenant:billing.write",
"justification": "Close the March invoice run",
"ttlSeconds": 900
}'scope(required) — must match an eligibility the subject holds, or you get403 pim_not_eligible.justification(optional) — free text, stored on the activation for the audit trail.ttlSeconds(optional, default3600) — capped server-side at the eligibility'smaxTtlSeconds.
The response is 201 with the activation. Its status is:
ACTIVEimmediately, withactivatedAtandexpiresAtset, when the eligibility hasrequireApproval: false; orPENDING_APPROVAL, with no expiry yet, when the eligibility requires approval — proceed to Step 3.
Step 3 — Approve a pending activation (reviewer)
A reviewer holding tenant:pim.approve approves a PENDING_APPROVAL activation. Approval
transitions it to ACTIVE and stamps activatedAt = now, expiresAt = now + ttlSeconds, and the
reviewer's sub for the audit chain.
curl -sS -X POST https://api.stg.thoryn.org/api/v1/pim/activations/$ID/approve \
-H "Authorization: Bearer $REVIEWER_TOKEN"Approving an activation that is not PENDING_APPROVAL returns 409 pim_invalid_state; an id that
is not in the reviewer's tenant returns 404.
Step 4 — How the active scope reaches the access token
An eligibility and an ACTIVE activation are records in product-api. They start granting
privilege only when the scope reaches a live access token — an ID-token claim enforces nothing
at a resource server. That bridge is the hub's ElevatedScopeCustomizer, wired into the
token-mint customizer chain (see the Token lifecycle
architecture page). The design is recorded in ADR
2026-08-03-pim-entitlement-token-enrichment.md (SSO-1321 / SSO-1322).
On every access-token mint — a fresh login or a refresh — for a client whose tenant enabled
the pim source:
- The hub calls
product-api's in-cluster enrichment endpointPOST /internal/claims-enrichment(HMAC-signed, off the JWT-signing thread — the same bounded, IO-dispatched transport the claims-enrichment webhook uses, peradrs/2026-07-02-token-claims-enrichment-auto-wire.md). product-apiresolves the subject's currently-ACTIVE, unexpired PIM scopes (ElevatedScopeResolver→PimService.getActiveScopes, cached in Redis for a short TTL) and returns them on the response'sscopeschannel.- The hub filters that set to the
tenant:*namespace (ElevatedScopeExtractor— a hard security floor) and unions the survivors into the access token'sscopeclaim. - The capability-entitlement gate then runs as a backstop, still dropping any
tenant:<family>.*scope whose family the tenant is not entitled to (peradrs/2026-06-17-tenant-capability-entitlement.md).
The subject's next access token now carries tenant:billing.write, and
@PreAuthorize("hasAuthority('SCOPE_tenant:billing.write')") passes.
Two consequences worth internalising:
- You need a fresh token. Elevation is applied at mint time; a token the subject already holds does not retroactively gain the scope. Re-authenticate or refresh after activation.
- Access token only. Scopes are authorization, not identity — the elevated scope is never put on the ID token or the refresh token.
GET /api/v1/pim/activations/active-scopes returns the subject's currently-active elevated scopes
if you want to confirm state without minting a token.
Step 5 — Expiry and revocation
Elevation is designed to switch itself off:
- Auto-expiry.
PimExpiryTaskruns a sweep every 60 seconds, transitioning anyACTIVEactivation whoseexpiresAthas passed toEXPIREDand writing an audit row. The real-timegetActiveScopesread also filters onexpiresAt, so a scope stops resolving into new tokens the instant it lapses, even before the sweep marks the row. - Revocation.
POST /api/v1/pim/activations/{id}/revoketransitions anACTIVEorPENDING_APPROVALactivation toREVOKED. It accepts eithertenant:pim.approveortenant:pim.elevate, so a subject can stand their own elevation down, and a reviewer can pull someone else's. Revoking an already-terminal activation returns409 pim_invalid_state.
After expiry or revocation the scope stops resolving, and the subject's next access token no longer carries it (bounded by the enrichment cache TTL — see below).
Endpoint reference
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/pim/eligible-assignments | tenant:pim.admin | Create an eligibility |
GET | /api/v1/pim/eligible-assignments | tenant:pim.admin | List the tenant's eligibilities |
GET | /api/v1/pim/eligible-assignments/{id} | tenant:pim.admin | Read one eligibility |
DELETE | /api/v1/pim/eligible-assignments/{id} | tenant:pim.admin | Revoke an eligibility |
GET | /api/v1/pim/eligible | tenant:pim.elevate | List the caller's own eligibilities |
POST | /api/v1/pim/activations | tenant:pim.elevate | Request an activation (step-up required) |
GET | /api/v1/pim/activations | tenant:pim.elevate | List the caller's activations |
GET | /api/v1/pim/activations/active-scopes | tenant:pim.elevate | List currently-active elevated scopes |
POST | /api/v1/pim/activations/{id}/approve | tenant:pim.approve | Approve a pending activation |
POST | /api/v1/pim/activations/{id}/revoke | tenant:pim.approve or tenant:pim.elevate | Revoke an activation |
The generated, always-current contract for these endpoints is in the API reference: Pim (the activation flow) and PimEligibilityAdmin (the eligibility CRUD).
Configuration
Enable the pim enrichment source on the client. The scope-union call only fires for a client
whose tenant has turned on the pim claims-in-token source — the same per-client toggle that gates
the claims channel. Enable it through the client's claims-in-token configuration; see
Claims in the token. Without it, activations still record correctly but no
scope reaches the token.
Hub → product-api enrichment (defaults are in-cluster; override only if your topology differs):
oauthy:
token-claims:
# In-cluster URL the hub calls to resolve a subject's elevated scopes.
enrichment-url: http://thoryn-product-api:8082/internal/claims-enrichment
# Short backstop TTL (seconds) for the Redis-cached elevated-scope lookup on the
# access-token mint hot path. A just-revoked/expired scope lingers at most this long;
# a just-added one appears within it.
elevated-scope-cache-ttl-seconds: 10Step-up ACR. The value an activation request must present in its acr claim is
urn:thoryn:acr:step-up:high. Configure your step-up policy so eligible subjects can reach it.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
403 pim_step_up_required on POST /activations | The request token has no acr = urn:thoryn:acr:step-up:high | Re-authenticate through step-up before requesting activation |
403 pim_not_eligible on POST /activations | No eligibility for that (subject, scope) in this mode | An admin must create the eligibility first; confirm the token's sub matches the eligibility's subjectId |
403 scope_not_grantable on POST /eligible-assignments | The scope is not tenant:*, or the admin does not currently hold it | Grant an eligibility only for a tenant:* scope the admin holds |
409 eligibility_exists | A duplicate eligibility for the same (subject, scope) and mode | Reuse or delete the existing one |
409 pim_invalid_state | Approve/revoke against an activation in the wrong state | Only PENDING_APPROVAL can be approved; only non-terminal activations can be revoked |
Activation is ACTIVE but the resource server still returns 403 | The subject is still holding the pre-activation token; the client's pim source is off; the cache TTL has not elapsed; the tenant is not entitled to that scope family; or the token's mode (test/live) differs from the activation's | Mint a fresh token (re-login or refresh); enable the pim source; wait out elevated-scope-cache-ttl-seconds; check the tenant's capability entitlement; match the mode |
404 on read/approve/revoke/delete by id | The id belongs to another tenant or another mode | Expected — cross-tenant/cross-mode access is 404 by design, not 403 |
Security notes
- Step-up is mandatory for every activation. No
ACTIVEelevation exists without a high-ACR request behind it; the presentedacris stored on the activation as audit evidence. - You can only delegate what you hold. The namespace floor (
tenant:*only) plus the transitive-hold check mean an admin can never make a subject eligible for more than the admin holds, andadmin:*(operator-plane) scopes are structurally unreachable. - Defence in depth at the hub. The hub independently filters the enrichment response to
tenant:*before the union, so even a buggy or compromisedproduct-apicannot inject an operator-plane scope into a customer-plane access token. The capability drop-gate is a further backstop. - Fail-closed on privilege. The scope-union path is privilege-adding, so it is deliberately
fail-safe: any enrichment failure — timeout,
product-apidown, non-2xx, malformed body — results in the token being issued without the elevated scopes. A failure makes the subject under-privileged (a403they retry), never over-privileged, and it never breaks token issuance or login. - Elevation is time-bounded. TTLs are capped at the eligibility ceiling and auto-expire. The only over-privilege window is the short cache TTL after a revoke/expire — strictly shorter than the already-issued token's own lifetime, and still subject to the hub's drop-gate.
- Tenant, environment, and mode isolation, full audit. Every operation is scoped by the
tntclaim, by the caller's resolved environment, and by the token's test/live mode — a sandbox admin sees and mutates only its own environment's eligibilities and activations, and a cross-environment id resolves to404(no existence leak). Every transition (create eligibility, request, approve, revoke, expire) writes an audit row under thepimretention category.
See also
- Token lifecycle — where
ElevatedScopeCustomizersits in the mint-time customizer chain. - Claims in the token — enabling the per-client enrichment sources, including
pim. - Authorize with roles, permissions, and relationships (RBAC + FGA) — the standing authorization models PIM elevates on top of.
- Risk-based step-up — how a subject reaches the high ACR an activation requires.