Skip to content

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) or PENDING_APPROVAL (awaiting a reviewer), and always carries an expiry.
  • The token reach — an ACTIVE activation's scope is unioned into the access token's scope claim 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 migration V110__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 pim claims-in-token source enabled (see Configuration).
  • Step-up authentication configured, so an eligible subject can obtain an acr of urn:thoryn:acr:step-up:high in 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 the tenant:* namespace.
  • maxTtlSeconds (optional, default 3600) — the ceiling an activation's TTL is capped to.
  • requireApproval (optional, default true) — whether an activation needs a reviewer before it goes ACTIVE.

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):

  1. Namespace floor. Only tenant:* scopes are elevatable. An admin:* (operator-plane) scope or an OIDC building block (openid / profile / …) can never be made elevatable.
  2. 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 tenant

Step 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 get 403 pim_not_eligible.
  • justification (optional) — free text, stored on the activation for the audit trail.
  • ttlSeconds (optional, default 3600) — capped server-side at the eligibility's maxTtlSeconds.

The response is 201 with the activation. Its status is:

  • ACTIVE immediately, with activatedAt and expiresAt set, when the eligibility has requireApproval: false; or
  • PENDING_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:

  1. The hub calls product-api's in-cluster enrichment endpoint POST /internal/claims-enrichment (HMAC-signed, off the JWT-signing thread — the same bounded, IO-dispatched transport the claims-enrichment webhook uses, per adrs/2026-07-02-token-claims-enrichment-auto-wire.md).
  2. product-api resolves the subject's currently-ACTIVE, unexpired PIM scopes (ElevatedScopeResolverPimService.getActiveScopes, cached in Redis for a short TTL) and returns them on the response's scopes channel.
  3. The hub filters that set to the tenant:* namespace (ElevatedScopeExtractor — a hard security floor) and unions the survivors into the access token's scope claim.
  4. The capability-entitlement gate then runs as a backstop, still dropping any tenant:<family>.* scope whose family the tenant is not entitled to (per adrs/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. PimExpiryTask runs a sweep every 60 seconds, transitioning any ACTIVE activation whose expiresAt has passed to EXPIRED and writing an audit row. The real-time getActiveScopes read also filters on expiresAt, 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}/revoke transitions an ACTIVE or PENDING_APPROVAL activation to REVOKED. It accepts either tenant:pim.approve or tenant:pim.elevate, so a subject can stand their own elevation down, and a reviewer can pull someone else's. Revoking an already-terminal activation returns 409 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

MethodPathScopeDescription
POST/api/v1/pim/eligible-assignmentstenant:pim.adminCreate an eligibility
GET/api/v1/pim/eligible-assignmentstenant:pim.adminList the tenant's eligibilities
GET/api/v1/pim/eligible-assignments/{id}tenant:pim.adminRead one eligibility
DELETE/api/v1/pim/eligible-assignments/{id}tenant:pim.adminRevoke an eligibility
GET/api/v1/pim/eligibletenant:pim.elevateList the caller's own eligibilities
POST/api/v1/pim/activationstenant:pim.elevateRequest an activation (step-up required)
GET/api/v1/pim/activationstenant:pim.elevateList the caller's activations
GET/api/v1/pim/activations/active-scopestenant:pim.elevateList currently-active elevated scopes
POST/api/v1/pim/activations/{id}/approvetenant:pim.approveApprove a pending activation
POST/api/v1/pim/activations/{id}/revoketenant:pim.approve or tenant:pim.elevateRevoke 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: 10

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

SymptomCauseFix
403 pim_step_up_required on POST /activationsThe request token has no acr = urn:thoryn:acr:step-up:highRe-authenticate through step-up before requesting activation
403 pim_not_eligible on POST /activationsNo eligibility for that (subject, scope) in this modeAn admin must create the eligibility first; confirm the token's sub matches the eligibility's subjectId
403 scope_not_grantable on POST /eligible-assignmentsThe scope is not tenant:*, or the admin does not currently hold itGrant an eligibility only for a tenant:* scope the admin holds
409 eligibility_existsA duplicate eligibility for the same (subject, scope) and modeReuse or delete the existing one
409 pim_invalid_stateApprove/revoke against an activation in the wrong stateOnly PENDING_APPROVAL can be approved; only non-terminal activations can be revoked
Activation is ACTIVE but the resource server still returns 403The 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'sMint 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 idThe id belongs to another tenant or another modeExpected — cross-tenant/cross-mode access is 404 by design, not 403

Security notes

  • Step-up is mandatory for every activation. No ACTIVE elevation exists without a high-ACR request behind it; the presented acr is 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, and admin:* (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 compromised product-api cannot 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-api down, non-2xx, malformed body — results in the token being issued without the elevated scopes. A failure makes the subject under-privileged (a 403 they 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 tnt claim, 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 to 404 (no existence leak). Every transition (create eligibility, request, approve, revoke, expire) writes an audit row under the pim retention category.

See also