Product documentation
Entitlement access packages
Bundle tenant:* scopes into a named access package, let a subject request it (with optional approval), and have the granted scopes reach the access token through the same enrichment channel PIM uses — then auto-expire. Grounded in the shipped product-api entitlement surface and the hub scope-union customizer.
Entitlement access packages
An access package is a named bundle of tenant:* scopes a tenant admin curates once, so a
subject can request the whole bundle instead of chasing individual scope grants. A request
resolves — with an optional approval step — into a time-bound assignment: the package's
scopes are granted to that subject for a bounded window, then the assignment expires on its own.
Where PIM hands out a single elevated scope for minutes at a
time behind a step-up wall, entitlement packages hand out a curated set of standing-but-bounded
access for days at a time — the difference between "let me touch billing for the next 15 minutes"
and "give the new analyst the reporting bundle for this quarter". Both features share the exact
same last mile: the granted scopes reach the access token's scope claim through one shared
enrichment channel (see Step 4).
The model has three moving parts:
- Package — a named, catalog-visible bundle of
tenant:*scopes with a per-assignment TTL ceiling and an approval policy. Created by an admin; grants nothing by itself. - Assignment — a subject's time-bound hold on a package. Ends up
ACTIVE(grant live) orPENDING_APPROVAL(awaiting a reviewer), and once active always carries an expiry. - The token reach — an
ACTIVEassignment's scopes are 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 assignment expires or is revoked, the next token no longer carries them.
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:entitlement.admin) requester (tenant:entitlement.read) reviewer (tenant:entitlement.approve)
──────────────────────────────── ─────────────────────────────────── ─────────────────────────────────────
POST /entitlement/packages
bundle of tenant:* scopes ────────▶ POST /entitlement/assignments
requireApproval=false ─▶ ACTIVE
requireApproval=true ─▶ PENDING_APPROVAL ─▶ POST /assignments/{id}/approve ─▶ ACTIVE
└▶ POST /assignments/{id}/deny ─▶ DENIED
│
next access-token mint: hub unions the ACTIVE package scopes into `scope` ◀───────────────────────┘
│
expiry (auto, ~60s sweep) or POST /assignments/{id}/revoke ─▶ EXPIRED / REVOKED
│
next access-token mint no longer carries the scopes
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 entitlement scopes were added to the grantable set in hub migrationV110__add_pim_entitlement_scopes_to_customer_plane_clients.sql. - For a token to actually carry a granted scope, the client the subject signs in through must
have the
entitlementsclaims-in-token source enabled (see Configuration). - The scopes you bundle into a package must be scopes the tenant genuinely holds — an elevated scope for a capability family the tenant is not entitled to is dropped at mint time by the capability gate (see Step 4).
Step 1 — Define an access package (admin)
Curating what may be granted is an admin privilege, so package creation is gated on
tenant:entitlement.admin. A package names a set of scopes, a TTL ceiling, and whether an
assignment needs approval.
curl -sS -X POST https://api.stg.thoryn.org/api/v1/entitlement/packages \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Reporting analyst",
"description": "Read-only reporting + audit access",
"scopes": ["tenant:reports.read", "tenant:audit.read"],
"requireApproval": true,
"maxAssignmentDays": 90
}'name(required) — a human label for the package; must not be blank.scopes(required) — the scopes granted when an assignment is active; at least one is required. These should betenant:*scopes the tenant holds — the capability gate is the backstop.requireApproval(optional, defaulttrue) — whether an assignment needs a reviewer before it goesACTIVE.maxAssignmentDays(optional, default90, must be ≥ 1) — the ceiling an assignment's window is capped to.
On success you get 201 with the package record (its id, the normalized sorted scopes,
isVisible, and createdBy). A blank name returns 400 invalid_name; an empty scope set returns
400 invalid_scopes; a maxAssignmentDays below 1 returns 400 invalid_max_assignment_days.
List the catalog on the same base path — it returns the tenant's visible packages, cursor-paginated, newest first:
curl -sS https://api.stg.thoryn.org/api/v1/entitlement/packages \
-H "Authorization: Bearer $ADMIN_TOKEN"Step 2 — Request an assignment (requester)
A subject requests a package with tenant:entitlement.read. Unlike PIM, there is no step-up
precondition — an entitlement request is a standing-access ask, reviewed by a human when the
package requires approval.
curl -sS -X POST https://api.stg.thoryn.org/api/v1/entitlement/assignments \
-H "Authorization: Bearer $REQUESTER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"packageId": "b6f1e2c0-1111-2222-3333-444455556666",
"justification": "Onboarding to the Q3 reporting rota",
"assignmentDays": 30
}'packageId(required) — the package to request. An id that is not a visible package in your tenant (and mode) returns404 package_not_found.justification(optional) — free text, stored on the assignment for the audit trail.assignmentDays(optional, default30, must be ≥ 1) — capped server-side at the package'smaxAssignmentDays.
The response is 201 with the assignment. Its status is:
ACTIVEimmediately, withassignedAtandexpiresAtset, when the package hasrequireApproval: false; orPENDING_APPROVAL, with no expiry yet, when the package requires approval — proceed to Step 3.
List your own assignments (all statuses, so you see the full history) with the same scope:
curl -sS https://api.stg.thoryn.org/api/v1/entitlement/assignments \
-H "Authorization: Bearer $REQUESTER_TOKEN"Step 3 — Approve or deny a pending assignment (reviewer)
A reviewer holding tenant:entitlement.approve resolves a PENDING_APPROVAL assignment.
Approval transitions it to ACTIVE and stamps assignedAt = now,
expiresAt = now + assignmentDays days, and the reviewer's sub; denial transitions it to
DENIED.
# Approve
curl -sS -X POST https://api.stg.thoryn.org/api/v1/entitlement/assignments/$ID/approve \
-H "Authorization: Bearer $REVIEWER_TOKEN"
# Deny
curl -sS -X POST https://api.stg.thoryn.org/api/v1/entitlement/assignments/$ID/deny \
-H "Authorization: Bearer $REVIEWER_TOKEN"Approving or denying an assignment that is not PENDING_APPROVAL returns
409 entitlement_invalid_state; an id that is not in the reviewer's tenant returns 404.
Step 4 — How a granted scope reaches the access token
A package and an ACTIVE assignment are records in product-api. They start granting privilege
only when the scopes reach a live access token — a management-API record 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). It is the same shared wire PIM rides — 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 entitlements 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 package scopes (ElevatedScopeResolver→EntitlementService.getActiveScopes, cached in Redis for a short TTL) and returns them on the response'sscopeschannel — the union of the scopes of every active assignment the subject holds.- 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 the package's scopes, and the matching
@PreAuthorize("hasAuthority('SCOPE_tenant:…')") checks pass.
Two consequences worth internalising:
- You need a fresh token. The grant is applied at mint time; a token the subject already
holds does not retroactively gain the scopes. Re-authenticate or refresh after the assignment
goes
ACTIVE. - Access token only. Scopes are authorization, not identity — a granted scope is never put on the ID token or the refresh token.
GET /api/v1/entitlement/assignments/active-scopes returns the subject's currently-granted scopes
if you want to confirm state without minting a token.
Step 5 — Expiry and revocation
An assignment is designed to switch itself off:
- Auto-expiry.
EntitlementExpiryTaskruns a sweep every 60 seconds, transitioning anyACTIVEassignment whoseexpiresAthas passed toEXPIREDand writing an audit row. The real-timegetActiveScopesread also filters onexpiresAt, so a scope stops resolving into new tokens the instant the assignment lapses, even before the sweep marks the row. - Revocation.
POST /api/v1/entitlement/assignments/{id}/revoketransitions anACTIVEorPENDING_APPROVALassignment toREVOKED. It acceptstenant:entitlement.approve,tenant:entitlement.read, ortenant:entitlement.admin, so a subject can stand their own assignment down and a reviewer or admin can pull someone else's. Revoking an already-terminal assignment (EXPIRED/REVOKED/DENIED) returns409 entitlement_invalid_state.
After expiry or revocation the scopes stop resolving, and the subject's next access token no longer carries them (bounded by the enrichment cache TTL — see Configuration).
Endpoint reference
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/entitlement/packages | tenant:entitlement.read or .admin | List the tenant's visible packages |
POST | /api/v1/entitlement/packages | tenant:entitlement.admin | Create an access package |
POST | /api/v1/entitlement/assignments | tenant:entitlement.read or .admin | Request an assignment of a package |
GET | /api/v1/entitlement/assignments | tenant:entitlement.read or .admin | List the caller's assignments (all statuses) |
POST | /api/v1/entitlement/assignments/{id}/approve | tenant:entitlement.approve | Approve a pending assignment |
POST | /api/v1/entitlement/assignments/{id}/deny | tenant:entitlement.approve | Deny a pending assignment |
POST | /api/v1/entitlement/assignments/{id}/revoke | tenant:entitlement.approve, .read, or .admin | Revoke an assignment |
GET | /api/v1/entitlement/assignments/active-scopes | tenant:entitlement.read or .admin | List the caller's currently-granted scopes |
The generated, always-current contract for these endpoints is in the API reference: Entitlement.
Configuration
Enable the entitlements enrichment source on the client. The scope-union call only fires for
a client whose tenant has turned on the entitlements claims-in-token source — the same per-client
toggle that gates the claims channel and the PIM pim source. Enable it through the client's
claims-in-token configuration; see Claims in the token. Without it,
assignments still record correctly but no scope reaches the token.
Enrichment cache TTL. The subject's active package scopes are read through a short-TTL Redis
cache (ElevatedScopeCache) on the access-token mint hot path, keyed on (tenant, subject, mode).
A just-revoked or just-expired scope therefore lingers at most that TTL after the state change; a
just-granted one appears within it. This is the same bounded cache the PIM elevated-scope lookup
uses — see the hub token-claims configuration in the
PIM guide.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
400 invalid_scopes on POST /packages | The package was created with an empty scopes array | Include at least one tenant:* scope |
400 invalid_max_assignment_days on POST /packages | maxAssignmentDays was below 1 | Use a positive day count |
404 package_not_found on POST /assignments | The packageId is not a visible package in this tenant and mode | Confirm the package exists (and is visible) in the caller's tenant and test/live mode |
409 entitlement_invalid_state | Approve/deny against a non-PENDING_APPROVAL assignment, or revoke against an already-terminal one | Only pending assignments can be approved or denied; only non-terminal assignments can be revoked |
Assignment is ACTIVE but the resource server still returns 403 | The subject is still holding the pre-assignment token; the client's entitlements 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 assignment's | Mint a fresh token (re-login or refresh); enable the entitlements source; wait out the cache TTL; check the tenant's capability entitlement; match the mode |
404 on approve/deny/revoke 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
- Curate-then-grant. Only an admin holding
tenant:entitlement.admindefines the scope bundles; a requester never chooses arbitrary scopes, only a package the admin published. This keeps the set of grantable scopes under admin control. - Defence in depth at the hub. The hub independently filters the enrichment response to
tenant:*before the union (ElevatedScopeExtractor), so even a buggy or compromisedproduct-apicannot inject an operator-plane (admin:*) scope into a customer-plane access token. The capability drop-gate is a further backstop, so a package can never smuggle a scope past the tenant's own capability entitlement. - 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 — issues the token without the granted scopes. A failure makes the subject under-privileged (a403they retry), never over-privileged, and it never breaks token issuance or login. - Time-bounded. Assignment windows are capped at the package ceiling and auto-expire. The only over-privilege window is the short cache TTL after a revoke/expire — 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 caller sees and mutates only its own environment's catalog and assignments, and a cross-environment id resolves to404(no existence leak). Every transition (create package, request, approve, deny, revoke, expire) writes an audit row under theentitlementretention category.
Limitations
- No access-review tie-in yet. Active entitlement assignments are not surfaced in access-review or recertification campaigns today — a reviewer cannot recertify or bulk-revoke package assignments from a campaign. The recertification tie-in is a separate future story (SSO-1322 follow-up); until it lands, an assignment is governed only by its own expiry and explicit revocation.
See also
- Just-in-time privileged elevation (PIM) — the sibling feature that rides the same enrichment wire for single-scope, step-up-gated, minutes-long elevation.
- Token lifecycle — where
ElevatedScopeCustomizersits in the mint-time customizer chain. - Claims in the token — enabling the per-client enrichment sources.
- Authorize with roles, permissions, and relationships (RBAC + FGA) — the standing authorization models entitlement packages bundle scopes on top of.