Trusted-subsystem auth for multi-tenant API products¶
Status: adopted (DocGen, shipped). Applies to: every multi-tenant AltaForge API product — RA and SDP (a.k.a. SDE) next, and any future product that serves multiple client organizations behind one deployment.
This is an explanation of the pattern plus a how-to for adopting it. The reusable pieces all live in altaforge.auth; a product does not re-implement them.
The problem¶
A multi-tenant product (DocGen, RA, SDP) is called by several client apps, each serving a different external client organization. The naive model has every product validate every client's end-user login directly — so the product must hold per-tenant IdP/Entra config for every client, and learn about every new tenant. That does not scale and puts each client's identity config inside a shared product.
The model: two tiers, one seam¶
- Client apps (SITE, and each client's app) own end-user login — their own IdP (Entra, whatever). Entra lives only here.
- Products (DocGen, RA, SDP) are multi-tenant engines that never see an end-user IdP token and hold no per-tenant IdP config.
The seam between them is a minted assertion:
- The client app authenticates its end user (its concern), then mints a short-lived RS256 JWT asserting
{iss=<product-slug-of-the-app>, sub=user, org, roles, aud=<product>, exp}, signed with the app's own private key. - The product validates that token against a small, fixed trusted-minter registry (issuer → public key + the orgs that issuer is allowed to speak for), reads
{user, org, roles}, and serves that tenant.
The product trusts a handful of product minters, not a directory of end users. Adding a client = adding one registry entry, not per-tenant IdP plumbing.
[client app] authenticate end user (its IdP)
│ mint_trusted_subsystem_token(user, org, roles, issuer, key, kid, audience)
▼
RS256 JWT { iss, sub, org, roles, aud=<product>, exp }
│ Authorization: Bearer …
▼
[product] MintedTokenAuthBackend validates:
signature (issuer's key) · aud · exp/iat · org ∈ issuer.allowed_orgs
→ UserData { user_id, roles, organizations:[org] }
Building blocks (altaforge.auth) — do not re-roll¶
| Piece | Symbol | Side |
|---|---|---|
| Mint a minted assertion | altaforge.auth.client.mint_trusted_subsystem_token(...) |
client app |
| Validate minted assertions | altaforge.auth.MintedTokenAuthBackend(minters, *, audience) |
product |
| One trusted minter's config | altaforge.auth.TrustedMinter(issuer, allowed_orgs, public_key_pem=… | jwks_url=…) |
product |
| Default token lifetime | altaforge.auth.DEFAULT_MINT_TTL_SECONDS (300) |
shared |
audience is required on both sides — each product names its own identity (e.g. "docgen"), and the mint call must target it. There is no default; a blank audience is rejected.
Product-side wiring (what a product owns)¶
A product owns only: (1) its audience constant, (2) a registry loader that reads its trust config from the environment, and (3) selecting the backend. DocGen's version, for reference:
# <product>/auth_minted.py
DOCGEN_AUDIENCE = 'docgen'
def load_trusted_minters_from_env() -> dict[str, TrustedMinter]:
# parse <PRODUCT>_TRUSTED_MINTERS (JSON array) into issuer -> TrustedMinter
...
# <product>/server.py
return MintedTokenAuthBackend(load_trusted_minters_from_env(), audience=DOCGEN_AUDIENCE)
Trust-registry shape (<PRODUCT>_TRUSTED_MINTERS, JSON)¶
[
{
"issuer": "site-rfp",
"public_key_pem": "-----BEGIN PUBLIC KEY-----…", // or "jwks_url": "https://…"
"allowed_orgs": ["acme", "beta-corp"]
}
]
Exactly one key source per entry (public_key_pem or jwks_url). Two issuers may not share a key.
Security guarantees (inherited by using the backend)¶
- Anti-confused-deputy. A validly-signed token is still rejected if its
orgis outside that issuer'sallowed_orgs. A client app's key can only ever reach the orgs it is entitled to — it cannot pivot into another client's org by changing theorgclaim. This is the guarantee that makes the shared engine safe across clients. - Fail-closed. An unknown
issis rejected outright — there is no fallback backend. - Hardened validation. RS256 is pinned;
exp,iat,aud,issare all required (a token that omitsexpdoes not validate as non-expiring).
The typed-client convention¶
Every product ships a product-owned typed client; consumers import it and never hand-roll an HTTP client. The client takes its bearer token as a callable, so a fresh token is minted per HTTP request (including SSE reconnects) — a short-lived token never expires mid-operation.
client.some_call(..., bearer_token=mint_bearer_token_provider(user_id)) # a Callable[[], str]
Governance posture (deliberate, current)¶
These are conscious decisions for the current scale, not omissions:
- Token lifetime: 5 minutes, kept. Short TTL is the load-bearing simplification below.
- No key rotation / revocation machinery. Safe because of the 5-minute TTL — a leaked key is a 5-minute problem, not a permanent one. Do not remove the
expclaim; "no expiry + no revocation" together is unsafe and is not the design. - Minted roles are trusted. The backend enforces
org ∈ allowed_orgsbut does not gate roles — a first-party client mints the roles it needs. Add a per-issuerallowed_rolesgate only if a less-trusted consumer is ever onboarded. - API compatibility. Keep the
/v1surface and the typed client additive; make breaking changes via a new version with a deprecation overlap, so live consumers are never force-upgraded.
How to adopt in a new multi-tenant API product (RA, SDP, …)¶
- Validate with the shared backend. Replace any direct per-tenant IdP validation with
MintedTokenAuthBackend(load_..._from_env(), audience='<product>'). (RA today does direct per-tenant Entra validation — that is what this replaces.) - Define your audience + registry loader in the product (
<PRODUCT>_AUDIENCE,<PRODUCT>_TRUSTED_MINTERS). - Ship a product-owned typed client that takes a callable bearer token; consumers import it.
- Keep the governance posture above (5-min TTL, no revocation, trusted roles, additive
/v1). - Client apps mint with
mint_trusted_subsystem_token(..., audience='<product>')under their own key.
Nothing above requires building auth primitives — they are already in altaforge.auth (shipped in altaforge-libraries main). Adoption is wiring + a typed client, not a rebuild.