Docs

JWT shapes, platform vs tenant context, extension tokens, SSO.

Authentication

Seven auth layers. Six are JWT-shaped (tenant user, platform admin, impersonation, extension, access tokens, and a one-hour admin API test token for ad-hoc API calls); the seventh is the long-lived fy_pat_* API token for CI pipelines and external integrations.

1. Tenant user JWT

Minted by POST /auth/login (local auth) or synthesized at SSO callback. HS256, 24-hour TTL. Carries:

{
  sub: string;        // user_id
  email: string;
  tenant_id: string;  // authoritative — backends scope every query
  role: 'admin' | 'operator';
  impersonated_by?: string;  // platform admin email on impersonation
  iat: number;
  exp: number;
}

Every /tenant/* route validates the JWT and never truststenant_id from the request body. If a payload carries a tenant_id that disagrees with the JWT claim, the handler returns 403 Forbidden.

2. Platform admin JWT

Minted by POST /super/auth/login. Separate claim set (PlatformClaims) with no tenant_id — platform admins operate globally.

{
  sub: string;        // platform_admins.id
  email: string;
  role: 'super_admin';
  iat: number;
  exp: number;
}

The PlatformAdmin extractor only validates the signature + role. It does NOT look up the admin in the DB on every request — the token's HMAC is sufficient proof. This is intentional: lets the fastyoke-admin reassign-tenant CLI mint a platform JWT with a synthetic sub and still authenticate to the ops endpoints, which is how live tenant handoffs work.

3. Impersonation JWT

Mints via POST /super/impersonate/:tenant_id. Looks like a regular tenant JWT but with impersonated_by set to the platform admin's email. 1-hour TTL (short by design — impersonation is a support tool, not a workflow).

Every action taken under an impersonation token is logged in event_log with the impersonated_by email captured, so the audit trail distinguishes the admin's actions from the real user's.

4. Extension JWT

Minted when an extension loads in the admin shell. 15-minute TTL — compromised bundles have a bounded blast radius. Carries:

{
  sub: string;        // the user the extension is acting for
  email: string;
  tenant_id: string;
  role: 'admin' | 'operator';
  ext_id: string;     // manifest id, e.g. "acme.shift-heatmap"
  scopes: string[];   // manifest's required_scopes
  iat: number;
  exp: number;
}

ext_id distinguishes extension JWTs from regular user tokens. Handlers that want to reject extensions entirely check for its presence; handlers that care about provenance read it for audit logging. Scopes are advisory today — require_scope() enforcement lands in a later phase.

Not JWTs — opaque strings stored in access_tokens (tenant-scoped)

  • access_token_index (platform pivot). Used for the public workspace route: an operator shares a link like /public/validate/<token>, the frontend resolves it, renders a job-specific view, and the token is automatically bound to a single (job_id, entity_id, page_slug) triple.

6. Long-lived API tokens

Opaque bearer credentials for CI pipelines and external integrations. Mint once at Admin → Settings → API Tokens, copy the secret exactly once, then use it on the wire:

curl -H "authorization: Bearer fy_pat_<prefix><secret>" \
  https://fastyoke.example/api/v1/tenant/entities/vehicle?tenant_id=acme

Shapefy_pat_<8-char lookup prefix><secret tail>. The fy_pat_ literal is a deliberate grep-for-me marker (same family as GitHub's ghp_). The prefix is indexable on the platform side so the auth extractor can find the owning tenant without any header context; the secret tail is hashed (SHA-256) at rest and verified in constant time.

Scopes — At mint time, the operator picks a subset of the scope vocabulary. Every request made with the token is gated by require_scope(); a token with only data:read cannot call a data:write endpoint. admin:* acts as a wildcard for teams that want a single rotate-me-please key, but narrower grants are strongly preferred.

Lifecycleexpires_at defaults to 90 days; operators can choose 30d / 90d / 1y / never at mint time. Revocation is a one-way soft-delete: DELETE /tenant/api-tokens/:id flips revoked_at and future requests fail closed with 401. The row stays on the ledger so the audit trail survives ("when was this token revoked, and by whom?" is always answerable).

Hard refusals — API tokens cannot install, mint, uninstall, or activate extensions; cannot mint further API tokens; cannot revoke other API tokens. A leaked token must never be able to permanently graft itself into the tenant, and the refusals are enforced in the extensions subsystem and the API-token subsystem as a belt-and-suspenders pair with the session-only checks.

See the CI scripting recipe for the full mint → curl → revoke walk-through.

7. API test token JWT (one-hour bearer)

A convenience tier added on top of the fy_pat_ family for ad-hoc API testing without the full mint ceremony. The endpoint is POST /api/v1/tenant/api-tokens/test-token; the response is a standard JWT bearer (eyJ…) that mirrors the calling admin's session authority and expires in one hour (TEST_TOKEN_TTL_SECS = 3600).

It is not a fy_pat_ token — the hard refusals above do not apply. A test token can do whatever the minting admin can do, because it is a fresh session token. Treat it like a password.

Stateless — nothing is stored. The bearer is returned once in the response body and never reproducible. It cannot be revoked; wait for the TTL to expire if you've leaked one.

Anti-delegation — the mint endpoint refuses a non-human session (fy_pat_ or another test token) with 403 delegated_credential_refused. A leaked PAT cannot spin up an unscoped session-bearing JWT and bypass its own scope ceiling.

Audit — each mint emits a structured api_test_token_generated event with actor and tenant. The token itself is never logged.

See API tokens → Quick test token for the in-product UI walkthrough and the full response shape.

Scopes

The current scope vocabulary:

ScopeGrants
data:read, data:writeRead / write entity records + schemas.
workflow:read, workflow:execute, workflow:adminJobs list, transitions, FSM admin.
files:read, files:writeUpload / download entity-file attachments.
forms:read, forms:write, forms:adminForm CRUD (admin adds publish / archive / delete).
admin:*Wildcard — matches every scope check. Use sparingly.

These are the scopes that both extension manifests and API token mints draw from — adding a new scope is a coordinated change across the shared scope vocabulary in the extensions subsystem and the token mint UI.

Multi-tenancy invariant

Every SQL statement touching tenant-scoped tables must include WHERE tenant_id = ?. This is the platform's prime directive — violating it is a critical security bug. The CurrentUser extractor resolves the per-tenant SqlitePool via tenant_pool_manager.get_pool() so handlers can't accidentally query another tenant's data even if they bind the wrong tenant_id string.

See also

Authentication establishes who you are. See Permissions for what you can do.