Skip to content

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:

  1. 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.
  2. 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 org is outside that issuer's allowed_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 the org claim. This is the guarantee that makes the shared engine safe across clients.
  • Fail-closed. An unknown iss is rejected outright — there is no fallback backend.
  • Hardened validation. RS256 is pinned; exp, iat, aud, iss are all required (a token that omits exp does 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 exp claim; "no expiry + no revocation" together is unsafe and is not the design.
  • Minted roles are trusted. The backend enforces org ∈ allowed_orgs but does not gate roles — a first-party client mints the roles it needs. Add a per-issuer allowed_roles gate only if a less-trusted consumer is ever onboarded.
  • API compatibility. Keep the /v1 surface 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, …)

  1. 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.)
  2. Define your audience + registry loader in the product (<PRODUCT>_AUDIENCE, <PRODUCT>_TRUSTED_MINTERS).
  3. Ship a product-owned typed client that takes a callable bearer token; consumers import it.
  4. Keep the governance posture above (5-min TTL, no revocation, trusted roles, additive /v1).
  5. 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.