Skip to content

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) or PENDING_APPROVAL (awaiting a reviewer), and once active always carries an expiry.
  • The token reach — an ACTIVE assignment's scopes are 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 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 migration V110__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 entitlements claims-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 be tenant:* scopes the tenant holds — the capability gate is the backstop.
  • requireApproval (optional, default true) — whether an assignment needs a reviewer before it goes ACTIVE.
  • maxAssignmentDays (optional, default 90, 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) returns 404 package_not_found.
  • justification (optional) — free text, stored on the assignment for the audit trail.
  • assignmentDays (optional, default 30, must be ≥ 1) — capped server-side at the package's maxAssignmentDays.

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

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

  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 package scopes (ElevatedScopeResolverEntitlementService.getActiveScopes, cached in Redis for a short TTL) and returns them on the response's scopes channel — the union of the scopes of every active assignment the subject holds.
  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 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. EntitlementExpiryTask runs a sweep every 60 seconds, transitioning any ACTIVE assignment 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 the assignment lapses, even before the sweep marks the row.
  • Revocation. POST /api/v1/entitlement/assignments/{id}/revoke transitions an ACTIVE or PENDING_APPROVAL assignment to REVOKED. It accepts tenant:entitlement.approve, tenant:entitlement.read, or tenant: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) returns 409 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

MethodPathScopeDescription
GET/api/v1/entitlement/packagestenant:entitlement.read or .adminList the tenant's visible packages
POST/api/v1/entitlement/packagestenant:entitlement.adminCreate an access package
POST/api/v1/entitlement/assignmentstenant:entitlement.read or .adminRequest an assignment of a package
GET/api/v1/entitlement/assignmentstenant:entitlement.read or .adminList the caller's assignments (all statuses)
POST/api/v1/entitlement/assignments/{id}/approvetenant:entitlement.approveApprove a pending assignment
POST/api/v1/entitlement/assignments/{id}/denytenant:entitlement.approveDeny a pending assignment
POST/api/v1/entitlement/assignments/{id}/revoketenant:entitlement.approve, .read, or .adminRevoke an assignment
GET/api/v1/entitlement/assignments/active-scopestenant:entitlement.read or .adminList 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

SymptomCauseFix
400 invalid_scopes on POST /packagesThe package was created with an empty scopes arrayInclude at least one tenant:* scope
400 invalid_max_assignment_days on POST /packagesmaxAssignmentDays was below 1Use a positive day count
404 package_not_found on POST /assignmentsThe packageId is not a visible package in this tenant and modeConfirm the package exists (and is visible) in the caller's tenant and test/live mode
409 entitlement_invalid_stateApprove/deny against a non-PENDING_APPROVAL assignment, or revoke against an already-terminal oneOnly pending assignments can be approved or denied; only non-terminal assignments can be revoked
Assignment is ACTIVE but the resource server still returns 403The 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'sMint 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 idThe id belongs to another tenant or another modeExpected — cross-tenant/cross-mode access is 404 by design, not 403

Security notes

  • Curate-then-grant. Only an admin holding tenant:entitlement.admin defines 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 compromised product-api cannot 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-api down, non-2xx, malformed body — issues the token without the granted scopes. A failure makes the subject under-privileged (a 403 they 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 tnt claim, 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 to 404 (no existence leak). Every transition (create package, request, approve, deny, revoke, expire) writes an audit row under the entitlement retention 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