Module kit / Identity and auth
Gateway API-key auth
identity-auth/gateway-api-key-auth
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | yes |
| Stack | fastapi, sqlalchemy-2, postgres, python-3.13 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope, _shared/alembic-migrations, _shared/test-layout |
| Frontends | identity-auth/ui/chat-signin-and-workspace-picker, identity-auth/ui/developer-portal-keys-page |
| Reference implementation |
|
| Measured on | prima feature/per-model-pricing cd2c248 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
A caller presents a prima_pk_ key, in X-API-Key or as a stock SDK's Authorization: Bearer, and the gateway resolves it to an Identity (subject, tenant, scopes, no PII) with one local table read and no call to any other service, then meters the request against that tenant. The invariant that makes it trustworthy is that acceptance is a SHA-256 hash lookup in a table the gateway owns, followed by a policy that fails closed on every branch: the key must be active, its subject must be active as read on this request, it must carry ai and no wildcard, it must not be expired, suspended or exhausted, and when an IP allow-list is set the peer must be on it (prima/auth/keys.py lines 159 to 213 and prima/seams/identity.py 459 to 532 and 621 to 689, read 2026-09-12 at cd2c248).
D3 in force: the standalone prima repo is the reference. Labs carries an older copy of this door (backend/services/primaapi/identity.py) that still answers on the Labs hosts under /api/prima/v1; it is described below only where it differs, and a new gateway copies Prima's files, not that one.
When to use
Use it for every route under the gateway prefix that a paying customer calls with a key: completions, embeddings, models, usage, whoami, and the chat server's proxy on the customer's behalf. Trigger phrases: "the key 401s on api.prima.li", "invalid_api_key", "insufficient_scope", "the SDK sends Bearer", "which tenant is this key", "revoke everything this customer has", "suspend the subject", "whoami says degraded", "does the gateway call Labs".
Do not use it for how a key comes to exist (identity-auth/developer-api-keys-and-security-policy, B10, mints in Labs; identity-auth/key-sync-outbox-labs-to-prima, B12, carries it here), for the Bearer door's federation with Labs sessions (identity-auth/labs-jwt-federation-door, B13: the same resolve function, the other branch, which at cd2c248 answers 503 for every Labs Bearer because no introspector is wired), for Labs' own X-API-Key dependency on non-gateway routes (S5), or for the surface question a human session raises (B4).
Stack assumptions
Pinned as measured at cd2c248 (2026-09-12): Python 3.13.7 (.venv), SQLAlchemy 2 with Mapped models on a dedicated AuthBase (prima/auth/models.py 114), FastAPI with Annotated dependencies, Postgres with gen_random_uuid(). S4's layout applies to Prima as the reference for S7 (the OpenAI error object on the gateway prefix). The policy module imports nothing that can reach Labs, and tests/auth/test_import_graph.py walks the real import closure to keep it so; the test suite as a whole runs with no Postgres, no network and no Labs (tests/conftest.py).
Environment names (prima/config.py, the PRIMA_* map at lines 1050 to 1090) that this door reads:
| Name | Behaviour |
|---|---|
PRIMA_DATABASE_URL | the one storage dependency of the revenue path; unreachable means every key request fails (500), never a fallback |
PRIMA_PUBLIC_BASE_URL | becomes Identity.issuer on a key identity (wiring.py 213, self_issuer); never used to verify anything |
PRIMA_JWT_ISSUER, PRIMA_JWKS_URL, PRIMA_JWKS_CACHE_PATH, PRIMA_JWKS_REFRESH_SECONDS | the Bearer door (B13); a key request does not read them |
PRIMA_KEY_SYNC_SECRET | the seam that writes this table (B12); the door itself never reads it |
There is no flag that turns the key door off, and there is no PRIMA_LABS_KEY_LOOKUP_UNTIL: the read-through to Labs on a local miss is deliberately absent (identity.py 631 to 638).
Data model
Prima owns the credential table outright; Labs keeps api_keys for its own scopes (keys.py 15 to 32).
-- prima/auth/models.py 131 to 253; migrations/versions/20260803_0001_auth_seam.py (prima_auth_001) and
-- 20260804_0001_key_sync_seq.py (prima_key_sync_001). Token CHECKs come from prima/content_free.py::token_check.
prima_subjects (
id uuid PRIMARY KEY,
issuer varchar(255) NOT NULL,
external_sub varchar(255) NOT NULL, -- the Labs users.id as it appears in a JWT sub, for federated rows
is_native boolean NOT NULL,
email varchar(255) NULL, -- CHECK (is_native OR email IS NULL): a federated subject carries no PII
status varchar(32) NOT NULL DEFAULT 'active', -- 'active' | 'suspended'; the FIND-011 column
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (issuer, external_sub) -- uq_prima_subjects_issuer_sub
);
prima_tenants (
id uuid PRIMARY KEY, -- the Labs workspaces.id VERBATIM; never re-keyed (the metering chain hashes it first)
issuer varchar(255) NOT NULL,
is_internal boolean NOT NULL DEFAULT false, -- the Labs consumer tenant; post-paid and uncapped
created_at timestamptz NOT NULL DEFAULT now()
);
prima_api_keys (
id uuid PRIMARY KEY,
subject_id uuid NOT NULL REFERENCES prima_subjects(id) ON DELETE RESTRICT,
tenant_id uuid NOT NULL REFERENCES prima_tenants(id) ON DELETE RESTRICT,
key_hash varchar(64) NOT NULL UNIQUE, -- uq_prima_api_keys_key_hash; sha256 hex; the credential of record
key_prefix varchar(32) NOT NULL, -- display only; never a validation input
scopes varchar(64)[] NOT NULL,
is_active boolean NOT NULL DEFAULT true,
expires_at timestamptz NULL,
max_uses integer NULL,
uses_count bigint NOT NULL DEFAULT 0,
allowed_ips varchar(64)[] NULL, -- CIDR strings compared in Python; not INET
suspended_until timestamptz NULL,
last_used_at timestamptz NULL,
sync_seq bigint NOT NULL DEFAULT 0, -- 0 = written by the backfill, 1+ = by the key-sync seam (B12)
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (cardinality(scopes) > 0), -- ck_prima_api_keys_scopes_nonempty
CHECK (NOT (scopes && ARRAY['*','admin','full']::varchar[])) -- ck_prima_api_keys_no_wildcard
);
CREATE INDEX ix_prima_api_keys_tenant ON prima_api_keys(tenant_id);
CREATE INDEX ix_prima_api_keys_subject ON prima_api_keys(subject_id);
The policy sees a frozen ApiKeyRecord (keys.py 111 to 130: id, subject_id, tenant_id, scopes, is_active, subject_active, expires_at, suspended_until, max_uses, uses_count, allowed_ips), built by SqlKeyRepository.find_by_hash from a join of the key and its subject's status (repository.py 41 to 62), so no code path can mutate a row while deciding on it. The resolved Identity (identity.py 284 to 313) carries subject_id, tenant_id, issuer, auth_method, scopes, surfaces, key_id, degraded, subject_active, cached_until, aud and no PII by construction.
What is not stored: the raw key (Labs hashes at mint and never keeps it; the seam carries the hash, B12); name (dropped on purpose, a label belongs to the surface that shows it, prima/api/keys_sync.py 297 to 302); an email for a federated subject (the CHECK); anomaly_score (absent on purpose, a heuristic calibrated on Labs' traffic, models.py 76 to 79), last_ip_address and force_rotation_at (Labs api_keys columns, alchemy_labs/backend/models/api_key_model.py 46 to 50 at fd8558f, that appear nowhere in the prima repo).
API surface
The door is a dependency, not a route: CurrentIdentity = Annotated[Identity, Depends(current_identity)] (prima/api/auth.py 108 to 144), declared by every handler under GATEWAY_PREFIX = "/api/prima/v1" (prima/errors.py 23). It reads three headers, Authorization, X-API-Key, X-Workspace-ID, and the peer address (_client_ip, 91 to 105, no X-Forwarded-For), and builds the provider per request with SqlKeyRepository, SqlSessionStore, SqlSubjectRepository and introspector=None (123 to 141). The dependency module is the composition point: it imports prima.auth.wiring, which holds the one permitted import of the Labs seam, so prima/api/auth.py sits outside the import-graph guard on purpose (see Invariants); what keeps Labs off a key request is introspector=None and _resolve_key taking no Labs branch, not the import graph.
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
every /api/prima/v1/* handler | CurrentIdentity | identity.tenant_id is prima_api_keys.tenant_id; X-Workspace-ID is ignored on the key door (it is read by _resolve_jwt only) | 401 invalid_api_key for a missing, malformed, unknown, inactive, expired, suspended or IP-refused key, or a suspended subject (one uniform body); 403 insufficient_scope (no ai), 403 scope_not_recognised (a wildcard reached runtime), 403 key_exhausted (max_uses reached); 503 identity_provider_unavailable with Retry-After: 30 for a Labs Bearer that decodes (B13) | the OpenAI object: {"error": {"message", "type": "invalid_request_error", "param": null, "code", "request_id"}} (errors.py 71 to 80, rendered on the gateway prefix by prima/app.py 60 to 81) |
GET /api/prima/v1/whoami | CurrentIdentity | as above | 200 | {subject_id, tenant_id, issuer, auth_method, aud, scopes, surfaces, cached_until, degraded, identity_provider: {federation_enabled, jwks_keys, jwks_age_seconds, revocation_lag_seconds, feed_state}} (152 to 184); for a key, auth_method is "api_key", aud is null, surfaces is [], cached_until is null, issuer is PRIMA_PUBLIC_BASE_URL, and federation_enabled is false at cd2c248. The other four identity_provider fields come from get_auth_runtime() (jwks, feed), which is B13's runtime and a build-time dependency of this handler; a gateway built to this recipe alone returns jwks_keys: 0, jwks_age_seconds: null, revocation_lag_seconds: null and feed_state: "unavailable", or omits the four until B13 lands, and the Argus check below reads only auth_method and degraded |
Credential order, as resolve (567 to 619) applies it: a Bearer that decodes as a Labs JWT wins; a Bearer that does not decode falls through to X-API-Key when present; with no X-API-Key, the Bearer value itself is tried as the key when it has the key shape (looks_like_prima_key, keys.py 102 to 108: ^[A-Za-z0-9_\-]{16,256}$, which a JWT with its dots never matches); a Bearer that is neither is 401. No cookie branch exists and the docstring at 543 to 549 says why (the Labs cookie is set on another site).
Legacy in-Labs copy (alchemy_labs/backend/services/primaapi/identity.py, GatewayCtx = Depends(gateway_identity) on primaapi_router.py line 55): the same three outcomes, with these differences at fd8558f: the Bearer-as-key fallback is absent (a stock SDK sending only Authorization: Bearer prima_pk_... is 401 "Could not validate credentials", 197 to 213); the cookie branch exists (_extract_bearer_or_cookie); errors are FastAPI HTTPExceptions with string details ("Invalid API key", "This API key does not carry the 'ai' scope required for PrimaAPI.", "API key expired", "API key has no bound workspace (tenant)" as a 400, "Key owner is inactive"), reshaped by app_fastapi.py into the OpenAI object with AUTH_001 or AUTHZ_001 as code; a wildcard in {"admin", "full", "*"} is honoured (151 to 152); the security service is called under a catch-all except Exception that logs and continues (175 to 182); the JWT door resolves a tenant from X-Workspace-ID or the single membership (98 to 136) instead of answering 503.
Application logic
GatewayIdentityProvider.resolve (prima/seams/identity.py 567 to 619) and _resolve_key (621 to 689), for a key request, in order:
_bearer(authorization)(828 to 837): the value afterBearer, header only. With a token, try_resolve_jwt; on_Undecodablefall to step 2 withapi_keyif present, else with the token itself if it looks like a key, else 401._resolve_key(api_key, client_ip): no value is 401"Missing credentials."; a value outside_KEY_SHAPEis the uniform 401 without a query (624 to 628).record = self.keys.find_by_hash(hash_key(api_key))(hash_keyissha256(raw.encode("utf-8")).hexdigest(),keys.py92 to 99; the repository joinsprima_subjects.statusintosubject_active,repository.py41 to 62). No row is the uniform 401; there is no read-through to Labs (631 to 638).check_key(record, now=utc now, required_scope="ai", client_ip)(keys.py159 to 213), each branch raising: inactive key 401; inactive subject 401; scopes intersecting{"*", "admin", "full"}403scope_not_recognised; noai403insufficient_scope;expires_at <= now401 (naive timestamps read as UTC,_as_aware, 148 to 156);suspended_until > now401;uses_count >= max_uses403key_exhausted;allowed_ipsset and the peer unknown or not in any CIDR entry 401 (_ip_allowed, 216 to 229: a malformed entry grants nothing and invalidates nothing).self.keys.note_use(record.id, when=now)(repository.py64 to 82):last_used_at = now,uses_count + 1, commit; onSQLAlchemyErrorroll back, logkey_use_bookkeeping_failed, continue. The one place a failed write does not fail a paid request.- FIND-011 belt and braces:
subject_active = record.subject_active and is_active_status(self.subjects.status_for(record.subject_id))when a subject repository is wired (653 to 657;current_identitywiresSqlSubjectRepository,auth.py131 to 134). - Log
identity_resolvedwith ids only (659 to 666), build theIdentitywithauth_method="api_key",issuer=self_issuer,surfaces=frozenset(),aud=None,key_id=record.id. authorize_gateway(identity)(459 to 532), the single gate both doors pass through:subject_activefalse is 401 first and unconditionally; forapi_key, a wildcard is 403scope_not_recognisedand a missingaiis 403insufficient_scope; for a session,audmust be among the grantedsurfaces(403surface_not_granted). A correctly resolved key always passes; the gate exists so a later weakening of either door still cannot let an identity out.- The handler runs:
list_models, the completion pipeline (v1.py242 to 274 readsidentity.tenant_idand checks the spend cap on it before any upstream call),whoami.
Suspension, suspend_subject (prima/auth/subjects.py 129 to 166): subjects.suspend(subject_id) sets status = 'suspended' where it is not already (repository.py 111 to 122, idempotent, returns whether a row changed), then sessions.revoke_subject marks every cached prima_sessions row; status first, so an interruption between the two still leaves the subject refused on the key door. The operator entrypoint is python -m scripts.suspend_subject --subject-id <uuid> or --issuer <iss> --external-sub <labs user id> (scripts/suspend_subject.py); there is no --reactivate, and lifting a suspension is a direct UPDATE prima_subjects SET status = 'active' by an operator (the docstring's "WHY REACTIVATION IS NOT HERE"). The key-sync seam refuses to activate a new key for a suspended subject with 400 subject_suspended (B12), which is what makes suspension hold against a fresh magic-link login that mints a replacement key.
Backfill, scripts/backfill_api_key.py (82f6e5d, 2026-08-03): reads the raw key from stdin, never a flag; inserts the subject (ON CONFLICT (issuer, external_sub) DO NOTHING), the tenant with the id given (ON CONFLICT (id) DO NOTHING) and the key with scopes forced to ['ai'] and ON CONFLICT (key_hash) DO NOTHING (74 to 94); prints the first eight hex characters of the hash (108 to 110), never the key and never the whole hash. A backfilled row has sync_seq = 0, so the first genuine seam message (seq 1) re-points it, including its subject_id (the healing described at keys_sync.py 469 to 474).
Invariants
- Acceptance is a hash lookup and never a prefix match: the shape check (
_KEY_SHAPE,keys.py89 and 102 to 108: any 16 to 256 character string of[A-Za-z0-9_-], no prefix test) decides only whether to query, andKEY_PREFIX(78 to 81) is display metadata that no code path reads (grep -rnw KEY_PREFIX prima/ scripts/hits its definition and__all__only,keys.py68 and 81); a dotless non-Prima key such assk-plus 40 characters is hashed and looked up, then refused by the miss (identity.py624 to 628, 631 to 638).test_gateway_scope_uniform.pyandtest_subject_suspension.pydrive the door throughfind_by_hashfakes and never through a prefix. - No Labs on the key path: nothing in the transitive import closure of the nine
KEY_PATH_MODULES(test_import_graph.py31 to 41:prima.seams.identity,prima.auth.keys,prima.auth.repository,prima.auth.sessions,prima.auth.claims,prima.auth.surfaces,prima.auth.jwks,prima.auth.jwt_ed25519,prima.auth.models) reachesprima/seams/labs_internal.pyorprima/auth/wiring.py; exactly one module (prima/auth/wiring.py, line 66) may import the seam (test_import_graph.py87 to 121,15 passed). The FastAPI dependency moduleprima/api/auth.pyis outside the guard: it importswiring(line 21) and so reaches the seam transitively (walking the test's own_closure("prima.api.auth")on 2026-09-12 finds both), which is legitimate for the composition point; the key request stays off Labs because it is built withintrospector=Noneand_resolve_keyhas no Labs branch. - The tenant is the key's
tenant_id, which is the Labsworkspaces.idverbatim and is never re-keyed; the metering chain hashes it as the first field (models.py167 to 185,keys_sync.py151 to 157). - A wildcard scope is refused at three layers: the CHECK constraint makes the row unwritable,
check_keydenies one that exists,authorize_gatewaydenies it again (models.py244 to 247,keys.py180 to 188,identity.py503 to 511);test_authorize_gateway_refuses_a_wildcard_key(test_gateway_scope_uniform.py167). - An
aikey is authorised identically on every channel that reachesresolve(direct or forwarded by the chat proxy):test_ai_key_is_authorized_the_same_on_every_channel(101),test_a_key_without_ai_is_refused_the_same_on_every_channel(113). - The subject's status is read on this request, from
prima_subjects, for both doors, and checked before scope and surface (identity.py500 to 501;test_the_subject_check_runs_before_scope_and_surface_checks,test_subject_suspension.py177); a subject with no local row is active, because every key row has a subject by FK (subjects.py68 to 76). - Every refusal a stolen key could probe with is the one uniform 401 (
invalid_api_key); onlyinsufficient_scope,scope_not_recognisedandkey_exhaustedare distinguishable, because a legitimate holder must act on them (keys.py166 to 171;test_401s_for_a_suspended_subject_are_indistinguishable_from_an_unknown_key, 199). - The IP allow-list fails closed when the peer is unknown (
keys.py208 to 213), and the peer is the socket address, never a header (auth.py91 to 105). Identityand thewhoamibody carry no email, name, role or company (identity.py284 to 291;auth.py155 to 159).- Bookkeeping never fails a request:
note_useswallowsSQLAlchemyErrorafter a rollback (repository.py72 to 81).
Frontend
The frontend modules that render this layer, by id:
identity-auth/ui/chat-signin-and-workspace-picker the chat server holds the per-session ai key and presents it as
X-API-Key on every gateway call (prima-chat w5-billing-panel
743b840, server/entitlement.ts 77, 166 to 167: the entitlement
check requires scopes to include ai and no wildcard, the same
rule as authorize_gateway); sign-out reports whether the
gateway key was revoked
identity-auth/ui/developer-portal-keys-page /schluessel lists Labs' view of the keys; the credential for
GET /api/prima/v1/usage is the per-session ai key the portal
mints at login, not a key created on that page ("the key is the
tenant", prima-developer w6-konto c594dfc, src/lib/prima-api.ts
448 and getUsage at 1247, A-2 (a)); a 403 on /api/developer/keys
with that key is the prefix lock explained
ai-gateway/ui/developer-portal-playground-and-models (planned as a file) the playground sends the key to the real
gateway; a 401 invalid_api_key or 403 insufficient_scope is
rendered from the OpenAI error object's message
Minimum admin and debug surface: GET /api/prima/v1/whoami with the key (which door, which tenant, whether federation is on), the log events identity_resolved (auth, subject_id, tenant_id, key_id, degraded, no PII) and key_use_bookkeeping_failed, python -m scripts.suspend_subject for the one-way door, and the SQL SELECT key_prefix, is_active, expires_at, suspended_until, uses_count, max_uses, sync_seq FROM prima_api_keys WHERE tenant_id = :t for a tenant's keys. No page lists Prima's keys; the portal lists Labs' view of them (F8).
Security & tenancy
Auth dependency: CurrentIdentity; on Labs' legacy copy, GatewayCtx. Workspace derivation: none for a key; the tenant is on the row and X-Workspace-ID is not consulted (a client that sends it is not refused; it is ignored). Fail direction: closed on every branch of check_key and authorize_gateway; closed on a missing subject repository only in the sense that subjects=None reverts to the pre-FIND-011 behaviour, where the key door still refuses through its own join and the JWT door checks nothing (identity.py 554 to 562; test_subjects_unset_entirely_preserves_the_old_behaviour, 337); open only for the bookkeeping write.
Secrets at rest: key_hash only; the raw key never enters this service except as a request header, and no log line carries it (identity_resolved logs ids). The seam credential that can write this table (PRIMA_KEY_SYNC_SECRET) is B12's and must differ from the other two internal credentials; prima/config.py refuses to boot otherwise (the _pairs table at 959 and the loop that raises ConfigError at 984 to 989).
Rate limits: none on the door itself (the spend cap and the per-tenant meter are the ai-gateway domain's).
Attacker-controlled inputs: Authorization (parsed as Bearer <value>, then decoded as a JWT or hashed as a key; a JWT can never match the key shape and a key can never verify as a JWT, so the two doors cannot be confused, 580 to 615); X-API-Key (shape-checked, then hashed; the shape check exists only to avoid a query); the peer address (request.client.host; behind the estate's proxy it is the proxy, so an allow-list denies everyone, which is why allowed_ips is left NULL until a trusted-proxy policy exists, auth.py 94 to 105); nothing in the body.
Tenancy of the credential: subject plus one tenant per key; a subject may hold keys in several tenants (the chat server's per-session key and the portal's key can be in the same tenant for the same subject). Suspension is per subject and reaches every key the subject holds now or acquires later (FIND-011); revocation is per key and is Labs' DELETE propagated by B12. There is no per-tenant suspension primitive at cd2c248.
Tests
Prima (prima/tests/auth/, run 2026-09-12 at cd2c248 with .venv/bin/python -m pytest <file> -q -o addopts="", all hermetic):
test_gateway_scope_uniform.py:9 passed.test_ai_key_is_authorized_the_same_on_every_channel(101),test_a_key_without_ai_is_refused_the_same_on_every_channel(113, parametrised),test_authorize_gateway_admits_an_ai_key(156),test_authorize_gateway_refuses_a_key_without_ai(161),test_authorize_gateway_refuses_a_wildcard_key(167), the three session cases (173 to 185).test_subject_suspension.py:21 passed.test_no_local_row_is_treated_as_active(102), the fourauthorize_gatewayadmit and refuse cases (152 to 168),test_the_subject_check_runs_before_scope_and_surface_checks(177),test_401s_for_a_suspended_subject_are_indistinguishable_from_an_unknown_key(199),test_a_valid_cached_session_is_refused_once_its_subject_is_suspended(219),test_the_exact_find_011_scenario_a_freshly_provisioned_key_is_still_refused(358),test_the_keys_own_join_can_still_refuse_it_with_no_subjects_store_wired(395),test_suspend_subject_writes_status_and_revokes_cached_sessions(414),test_suspend_subject_is_idempotent(433),test_there_is_no_reactivate_primitive(456).test_import_graph.py:15 passed.test_no_module_on_the_key_path_can_reach_labs(87, parametrised over the key-path modules),test_exactly_one_module_imports_labs_internal(100),test_no_auth_module_imports_a_jwt_library(149).test_repository_live.py:21 skippedwithoutPRIMA_TEST_DATABASE_URL; with a database,test_the_key_path_resolves_against_a_real_database(173),test_note_use_increments_and_stamps(191),test_a_disabled_subject_denies_the_key(199),test_suspend_subject_writes_the_status_a_real_key_lookup_then_sees(214).- The whole
tests/authtree:232 passed, 21 skipped in 6.05s.
Known-answer vectors: hash_key("prima_pk_" + "x" * 43) is 38e293003c756b7d6625c6b33e53b49d1a58ccef3ae8b89f5c9951f6c910b419 on Prima and hashlib.sha256(...) in Labs agrees (computed 2026-09-12 on both, prima/auth/keys.py 99 and developer_router.py 130); looks_like_prima_key is true for that string, true for "x" * 20 and for "sk-" + "x" * 40 (no prefix test, computed 2026-09-12), and false for any string containing a . (a JWT), which is the whole basis of the Bearer fallback. _ip_allowed("10.0.0.7", ("10.0.0.0/24",)) is true, ("not-a-cidr",) is false and does not raise (216 to 229).
Labs, legacy copy (alchemy_labs/backend/tests/test_primaapi_identity.py, run 2026-09-12 at fd8558f against the :5434 Postgres): 10 passed in 5.82s; test_ai_key_rejected_outside_gateway (68), test_ai_key_not_prefix_blocked_on_own_gateway_path (76), test_default_read_scope_key_rejected (104), test_missing_key_is_401_not_500 (122), test_signup_key_hash_actually_authenticates (162), test_minted_key_is_ai_scope_only_never_labs (181).
Hermetic versus live: everything above except the 21 skips is hermetic. Playwright gate: none here; the portal and chat recipes own theirs.
Deploy & ops
Config keys per app heredoc (S13), Prima: PRIMA_DATABASE_URL, PRIMA_PUBLIC_BASE_URL, PRIMA_JWT_ISSUER (the seam checks it, B12), PRIMA_KEY_SYNC_SECRET (B12), and the JWKS trio for the other door (B13). Migrations order: prima_auth_001 (20260803_0001_auth_seam.py, re-parented onto prima_credits_0002; its header records the two-heads incident and the rule to re-parent rather than merge) then prima_key_sync_001 (20260804_0001_key_sync_seq.py, sync_seq with server_default='0' so the backfilled row loses to the first real message). Scheduler jobs: none for the door; prima_sessions purge is B13's concern. Argus checks: GET /api/prima/v1/whoami with a monitoring key answers 200 with "auth_method":"api_key" and "degraded":false; the same with a random 52-character key answers 401 with "code":"invalid_api_key".
Rollback: the door is code over a table; a code rollback needs no data change. Suspension is one-way by design; to reverse one, an operator runs UPDATE prima_subjects SET status = 'active' WHERE id = :s and the cached sessions stay revoked (they re-introspect). A key wrongly refused after a scope change is fixed on the Labs side (B10 mint or re-mint), never by editing prima_api_keys.scopes by hand, because the next seam message with a higher sync_seq overwrites the hand edit.
Gotchas
- 2026-09-12 (measured at cd2c248): the FIND-011 subject suspension (
prima/auth/subjects.py,scripts/suspend_subject.py, thesubjectswiring incurrent_identity,test_subject_suspension.py; commits c14f552 and 9555ded, 2026-08-08) is onfeature/per-model-pricingand not onmain(git merge-base --is-ancestor c14f552 mainfails). The inventory row's "prod (subject suspension unmerged)" is exact. Thestatuscolumn itself has been inprima_subjectssinceprima_auth_001, so the schema is ready on every deployment and only the checks are branch-bound. - 2026-09-12 (measured at cd2c248): 9555ded fixed the JWT door's suspension lookup, which queried
prima_subjects WHERE id = :subwith a Labs user id that lives inexternal_sub, so it never matched a row (repository.py97 to 109 records it). The key door was never affected: it reads the status through the FK join. A rebuilt JWT door looks up by(issuer, external_sub). - 2026-09-12 (measured at cd2c248):
allowed_ipscannot be used behind the estate's proxy, and the code says so (auth.py91 to 105). An entry set today denies every request for that key; leave itNULL. Labs' legacy copy trustsX-Forwarded-Forinstead (B10 Gotchas), which is the opposite failure. - 2026-09-12 (measured at fd8558f, legacy copy, D3): the Bearer-as-key fallback that makes "change the base URL and the key" true for a stock SDK exists on Prima (
identity.py599 to 614, since 2026-08-04) and not inalchemy_labs/backend/services/primaapi/identity.py(197 to 213 requiresX-API-Keyalongside the junk Bearer). A developer hitting the Labs-hosted gateway path withOpenAI(api_key=...)and nothing else is 401. Described, not fixed: the reference is Prima. - 2026-09-12 (measured at fd8558f, legacy copy, D3): the Labs copy honours
{"admin", "full", "*"}asai(151 to 152) and runscheck_api_key_securityunder a catch-allexcept Exceptionthat logs "failed open" and continues (175 to 182;HTTPExceptionis re-raised first). Prima's CHECK constraint makes such a row unwritable and its policy runs with notry. This pair is why D3 names Prima as the reference. - 2026-09-12 (measured at cd2c248): the comment at
prima/seams/identity.py607 to 608 says the Bearer-as-key fallback is safe "because the shape check demands theprima_pk_prefix, which no JWT can carry". It does not:looks_like_prima_keyis_KEY_SHAPE.fullmatch(keys.py89, 108) andKEY_PREFIXis read nowhere. What excludes a JWT is the regex's lack of a.; a 16 to 256 character dotless Bearer of any vendor's shape is hashed and looked up, and refused by the miss, so the acceptance property holds and the stated mechanism is wrong. The fix it needs is one of two: makelooks_like_prima_keyrequireKEY_PREFIX(keeping the dot exclusion) so the comment becomes true, or correct the comment to name the regex. Recorded under D8, no code changed in this wave. - 2026-09-12 (measured at cd2c248):
check_keyreadssubject_activeoff the join and_resolve_keyasksself.subjectsagain (653 to 657). Two reads of the same column per request, on purpose; the comment explains that the second read is what makes the refusal independent of one repository implementation's join. A rebuilt door keeps both or replaces them with one repository that the testtest_the_keys_own_join_can_still_refuse_it_with_no_subjects_store_wiredstill passes against. - 2026-09-12 (measured at cd2c248):
whoamireportsfederation_enabled: falseas a literal (auth.py178), andcurrent_identitypassesintrospector=None(130), so every Labs Bearer that decodes is 503 withRetry-After: 30(S5 Gotchas, B13). A key request never touches that path; a support ticket that says "the gateway is down" with a 503 is a session, not a key. - 2026-09-12 (measured at cd2c248):
max_usesis a 403key_exhaustedon the read after the last permitted one, andnote_useincrements aftercheck_key, so a key withmax_uses = 1serves exactly one request. Labs' legacy security service deactivates the key permanently on the same condition (api_key_security_service.py72 to 77); Prima leaves it active and exhausted, which a highermax_usesreopens. - 2026-09-12 (measured at cd2c248): the wildcard tightening is real for migrated keys.
keys.py34 to 44 asks for an audit of Labsapi_keysfor{"admin", "full", "*"}before any backfill; a key that relied on the wildcard in Labs works on Prima only if rewritten to['ai'], whichscripts/backfill_api_key.pyforces and the seam refuses with 400unrepresentable_scopes(B12). - 2026-09-11 (S5 Gotchas, carried):
prima/auth/wiring.py's header still says Labs signs HS256 and that none of the four seam endpoints exists; one does. Docstring only; the key door is unaffected.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on, read 2026-09-12.
prima(feature/per-model-pricing, cd2c248):prima/auth/keys.py:KEY_PREFIX,AI_SCOPE,WILDCARD_SCOPES,_KEY_SHAPE(81 to 89),hash_key(92 to 99),looks_like_prima_key(102 to 108),ApiKeyRecord(111 to 130),KeyRepository(133 to 145),_as_aware(148 to 156),check_key(159 to 213),_ip_allowed(216 to 229).prima/auth/models.py:AuthBase(114),NAME_LEN(120),PrimaSubject(131 to 164),PrimaTenant(167 to 184),PrimaApiKey(187 to 253),PrimaSession(255 to 288),PrimaRevocationCursor(291 to 307).prima/auth/repository.py:SqlKeyRepository(35 to 82),SqlSubjectRepository(84 to 122).prima/seams/identity.py:Identity(284 to 313),GATEWAY_SCOPE(456),authorize_gateway(459 to 532),GatewayIdentityProvider(535 to 565),resolve(567 to 619),_resolve_key(621 to 689),_resolve_jwt(691 onward, B13),_bearer(828 to 837),build_identity_provider(839).prima/api/auth.py:_client_ip(91 to 105),current_identity(108 to 141),CurrentIdentity(144),whoami(152 to 184).prima/auth/subjects.py:ACTIVE_STATUS(62),SUSPENDED_STATUS(65),is_active_status(68 to 76),SuspendResult(120 to 126),suspend_subject(129 to 166).prima/auth/wiring.py: the issuer registration (180), the provider construction withself_issuer=settings.public_base_urlandsubjects(209 to 214).prima/errors.py:GATEWAY_PREFIX(23),PrimaError.body(71 to 80),authentication_error(108 to 111).prima/api/v1.py:CurrentIdentityimported at 94 and on the handlers (173list_models, 423, 921, 1092usage, the route the F8 line above depends on), the tenant read in the completion pipeline (256, 271 to 274).prima/runtime/jobs/identity.py39 aliasescurrent_identityasresolve_job_identity;prima/runtime/jobs/router.pymounts onGATEWAY_PREFIX(124) and declares it on the two/jobshandlers (223, 347), so those routes pass the same door under another name.migrations/versions/20260803_0001_auth_seam.py(prima_auth_001: tables 101 to 215,prima_api_keys142 to 178),migrations/versions/20260804_0001_key_sync_seq.py(prima_key_sync_001, 55 to 74).scripts/backfill_api_key.py(stdin key, the three inserts 74 to 94, the count check 100),scripts/suspend_subject.py.tests/auth/test_gateway_scope_uniform.py,tests/auth/test_subject_suspension.py,tests/auth/test_import_graph.py,tests/auth/test_repository_live.py.
alchemy_labs(d1a-token-source, fd8558f), the legacy in-Labs copy, D3:backend/services/primaapi/identity.py:AI_SCOPE,GATEWAY_KEY_PREFIX(57 to 59),GatewayIdentity(63 to 68),_resolve_tenant_for_jwt_user(98 to 136),_authenticate_ai_scoped_key(139 to 190),resolve_gateway_identity(193 to 236);backend/api/routers/primaapi_router.py(GatewayCtx, 55);backend/tests/test_primaapi_identity.py.- Wiki
philosophers_stone(6322fa3):platform/primaapi.md("The auth seam, as built", "Identity"),operations/decisions/0026-labs-to-prima-key-propagation.md,askedquestions/2026-08-20-primaapi-key-minting-needs-a-frontend.md,operations/prima-modules-inventory.mdrows B11, B13, F6, F8, F33.
Deviations of the reference from this recipe, all dated 2026-09-12: the suspension pieces unmerged into main; allowed_ips unusable behind the proxy; two subject-status reads per request; the prefix comment at identity.py 607 to 608 describing a check that does not exist; the legacy copy's missing Bearer fallback, honoured wildcard and fail-open security call; the stale wiring header.
Acceptance checks
Run from prima at cd2c248 with .venv active, or from a new gateway built to this recipe. $PRIMA_BASE is a running instance, $K an ai key synced or backfilled into it. Expected observations are exact.
python -m pytest tests/auth/test_gateway_scope_uniform.py tests/auth/test_subject_suspension.py tests/auth/test_import_graph.py -q -o addopts=""prints45 passed;python -m pytest tests/auth -q -o addopts=""prints232 passed, 21 skipped.python -c "from prima.auth.keys import hash_key, looks_like_prima_key; print(hash_key('prima_pk_' + 'x'*43)); print(looks_like_prima_key('prima_pk_' + 'x'*43), looks_like_prima_key('a.b.c'))"prints38e293003c756b7d6625c6b33e53b49d1a58ccef3ae8b89f5c9951f6c910b419thenTrue False;python -c "from prima.auth.keys import looks_like_prima_key; print(looks_like_prima_key('x'*20))"printsTrue(the shape check has no prefix test; see Invariants and Gotchas).python -c "from prima.auth.keys import _ip_allowed; print(_ip_allowed('10.0.0.7', ('10.0.0.0/24',)), _ip_allowed('10.0.1.7', ('10.0.0.0/24',)), _ip_allowed('10.0.0.7', ('not-a-cidr',)))"printsTrue False False.curl -s -H "X-API-Key: $K" $PRIMA_BASE/api/prima/v1/whoamiprints a JSON object with"auth_method":"api_key","aud":null,"surfaces":[],"scopes":["ai"],"degraded":falseand"federation_enabled":false; no key in the output contains an@.curl -s -H "Authorization: Bearer $K" $PRIMA_BASE/api/prima/v1/whoamiprints the same object (the Bearer-as-key fallback);curl -s -H "Authorization: Bearer not.a.key" -H "X-API-Key: $K" $PRIMA_BASE/api/prima/v1/whoamiprints the same object (the junk-Bearer fallback).curl -s -i -H "X-API-Key: prima_pk_$(python -c "import secrets; print(secrets.token_urlsafe(32))")" $PRIMA_BASE/api/prima/v1/whoamishows401and a body{"error":{"message":"Invalid authentication credentials.","type":"invalid_request_error","param":null,"code":"invalid_api_key","request_id":"..."}};curl -s -i $PRIMA_BASE/api/prima/v1/whoamiwith no credential shows401with"message":"Missing credentials."and the samecode.psql -c "UPDATE prima_api_keys SET scopes = ARRAY['*']::varchar[] WHERE key_hash = :h"fails withck_prima_api_keys_no_wildcard;psql -c "UPDATE prima_api_keys SET scopes = ARRAY[]::varchar[] ..."fails withck_prima_api_keys_scopes_nonempty;psql -c "UPDATE prima_api_keys SET scopes = ARRAY['read']::varchar[] WHERE key_hash = :h"succeeds and the nextwhoamiwith$Kis 403 with"code":"insufficient_scope"; restoreARRAY['ai'].python -m scripts.suspend_subject --subject-id <the subject of $K>prints a result withstatus_changed=True; the nextwhoamiwith$Kis 401invalid_api_key, byte for byte the body from check 6; running the script again printsstatus_changed=False;psql -c "UPDATE prima_subjects SET status = 'active' WHERE id = ..."and the key works again.psql -c "UPDATE prima_api_keys SET max_uses = uses_count + 1 WHERE key_hash = :h": the next request is 200, the one after is 403 with"code":"key_exhausted"; setmax_uses = NULL.grep -rnE '^(from|import) .*labs_internal' prima/auth/keys.py prima/seams/identity.py prima/api/auth.py prima/auth/repository.py | wc -lprints0(the unanchoredgrep -rn "labs_internal"over the same files prints5, all docstring and comment mentions:keys.py10,identity.py16, 240, 842, 851; the import-graph test in check 1 is the proof, this grep is the quick look);grep -c "X-Forwarded-For" prima/api/auth.pyprints1(the comment that says it is not read) andgrep -c 'headers.get("X-Forwarded' prima/api/auth.pyprints0.- (Legacy copy, from
alchemy_labs/backendat fd8558f with the:5434Postgres.)python -m pytest tests/test_primaapi_identity.py -q --no-cov -o addopts="" -p no:cacheproviderprints10 passed; with the app running,curl -s -i -H "Authorization: Bearer $LABS_AI_KEY" http://127.0.0.1:8000/api/prima/v1/modelsshows401(the fallback Prima has and this copy lacks) and adding-H "X-API-Key: $LABS_AI_KEY"shows200.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.