Prima Get an API key

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
  • prima/prima/auth/keys.py
  • prima/prima/auth/models.py
  • prima/prima/auth/repository.py
  • prima/prima/seams/identity.py
  • prima/prima/api/auth.py
  • prima/prima/auth/subjects.py
  • prima/migrations/versions/20260803_0001_auth_seam.py
  • prima/scripts/backfill_api_key.py
  • prima/scripts/suspend_subject.py
  • prima/tests/auth/test_import_graph.py
  • prima/tests/auth/test_subject_suspension.py
  • alchemy_labs/backend/services/primaapi/identity.py
  • alchemy_labs/backend/tests/test_primaapi_identity.py
Measured on prima feature/per-model-pricing cd2c248 2026-09-12
Also measured on
  • alchemy_labs d1a-token-source fd8558f 2026-09-12
  • prima-chat w5-billing-panel 743b840 2026-09-12
  • prima-developer w6-konto c594dfc 2026-09-12
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:

NameBehaviour
PRIMA_DATABASE_URLthe one storage dependency of the revenue path; unreachable means every key request fails (500), never a fallback
PRIMA_PUBLIC_BASE_URLbecomes 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_SECONDSthe Bearer door (B13); a key request does not read them
PRIMA_KEY_SYNC_SECRETthe 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.

RouteAuth depTenancyCodesNotes
every /api/prima/v1/* handlerCurrentIdentityidentity.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/whoamiCurrentIdentityas above200{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:

  1. _bearer(authorization) (828 to 837): the value after Bearer , header only. With a token, try _resolve_jwt; on _Undecodable fall to step 2 with api_key if present, else with the token itself if it looks like a key, else 401.
  2. _resolve_key(api_key, client_ip): no value is 401 "Missing credentials."; a value outside _KEY_SHAPE is the uniform 401 without a query (624 to 628).
  3. record = self.keys.find_by_hash(hash_key(api_key)) (hash_key is sha256(raw.encode("utf-8")).hexdigest(), keys.py 92 to 99; the repository joins prima_subjects.status into subject_active, repository.py 41 to 62). No row is the uniform 401; there is no read-through to Labs (631 to 638).
  4. check_key(record, now=utc now, required_scope="ai", client_ip) (keys.py 159 to 213), each branch raising: inactive key 401; inactive subject 401; scopes intersecting {"*", "admin", "full"} 403 scope_not_recognised; no ai 403 insufficient_scope; expires_at <= now 401 (naive timestamps read as UTC, _as_aware, 148 to 156); suspended_until > now 401; uses_count >= max_uses 403 key_exhausted; allowed_ips set and the peer unknown or not in any CIDR entry 401 (_ip_allowed, 216 to 229: a malformed entry grants nothing and invalidates nothing).
  5. self.keys.note_use(record.id, when=now) (repository.py 64 to 82): last_used_at = now, uses_count + 1, commit; on SQLAlchemyError roll back, log key_use_bookkeeping_failed, continue. The one place a failed write does not fail a paid request.
  6. 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_identity wires SqlSubjectRepository, auth.py 131 to 134).
  7. Log identity_resolved with ids only (659 to 666), build the Identity with auth_method="api_key", issuer=self_issuer, surfaces=frozenset(), aud=None, key_id=record.id.
  8. authorize_gateway(identity) (459 to 532), the single gate both doors pass through: subject_active false is 401 first and unconditionally; for api_key, a wildcard is 403 scope_not_recognised and a missing ai is 403 insufficient_scope; for a session, aud must be among the granted surfaces (403 surface_not_granted). A correctly resolved key always passes; the gate exists so a later weakening of either door still cannot let an identity out.
  9. The handler runs: list_models, the completion pipeline (v1.py 242 to 274 reads identity.tenant_id and 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.py 89 and 102 to 108: any 16 to 256 character string of [A-Za-z0-9_-], no prefix test) decides only whether to query, and KEY_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.py 68 and 81); a dotless non-Prima key such as sk- plus 40 characters is hashed and looked up, then refused by the miss (identity.py 624 to 628, 631 to 638). test_gateway_scope_uniform.py and test_subject_suspension.py drive the door through find_by_hash fakes 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.py 31 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) reaches prima/seams/labs_internal.py or prima/auth/wiring.py; exactly one module (prima/auth/wiring.py, line 66) may import the seam (test_import_graph.py 87 to 121, 15 passed). The FastAPI dependency module prima/api/auth.py is outside the guard: it imports wiring (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 with introspector=None and _resolve_key has no Labs branch.
  • The tenant is the key's tenant_id, which is the Labs workspaces.id verbatim and is never re-keyed; the metering chain hashes it as the first field (models.py 167 to 185, keys_sync.py 151 to 157).
  • A wildcard scope is refused at three layers: the CHECK constraint makes the row unwritable, check_key denies one that exists, authorize_gateway denies it again (models.py 244 to 247, keys.py 180 to 188, identity.py 503 to 511); test_authorize_gateway_refuses_a_wildcard_key (test_gateway_scope_uniform.py 167).
  • An ai key is authorised identically on every channel that reaches resolve (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.py 500 to 501; test_the_subject_check_runs_before_scope_and_surface_checks, test_subject_suspension.py 177); a subject with no local row is active, because every key row has a subject by FK (subjects.py 68 to 76).
  • Every refusal a stolen key could probe with is the one uniform 401 (invalid_api_key); only insufficient_scope, scope_not_recognised and key_exhausted are distinguishable, because a legitimate holder must act on them (keys.py 166 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.py 208 to 213), and the peer is the socket address, never a header (auth.py 91 to 105).
  • Identity and the whoami body carry no email, name, role or company (identity.py 284 to 291; auth.py 155 to 159).
  • Bookkeeping never fails a request: note_use swallows SQLAlchemyError after a rollback (repository.py 72 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 four authorize_gateway admit 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 skipped without PRIMA_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/auth tree: 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, the subjects wiring in current_identity, test_subject_suspension.py; commits c14f552 and 9555ded, 2026-08-08) is on feature/per-model-pricing and not on main (git merge-base --is-ancestor c14f552 main fails). The inventory row's "prod (subject suspension unmerged)" is exact. The status column itself has been in prima_subjects since prima_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 = :sub with a Labs user id that lives in external_sub, so it never matched a row (repository.py 97 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_ips cannot be used behind the estate's proxy, and the code says so (auth.py 91 to 105). An entry set today denies every request for that key; leave it NULL. Labs' legacy copy trusts X-Forwarded-For instead (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.py 599 to 614, since 2026-08-04) and not in alchemy_labs/backend/services/primaapi/identity.py (197 to 213 requires X-API-Key alongside the junk Bearer). A developer hitting the Labs-hosted gateway path with OpenAI(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", "*"} as ai (151 to 152) and runs check_api_key_security under a catch-all except Exception that logs "failed open" and continues (175 to 182; HTTPException is re-raised first). Prima's CHECK constraint makes such a row unwritable and its policy runs with no try. This pair is why D3 names Prima as the reference.
  • 2026-09-12 (measured at cd2c248): the comment at prima/seams/identity.py 607 to 608 says the Bearer-as-key fallback is safe "because the shape check demands the prima_pk_ prefix, which no JWT can carry". It does not: looks_like_prima_key is _KEY_SHAPE.fullmatch (keys.py 89, 108) and KEY_PREFIX is 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: make looks_like_prima_key require KEY_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_key reads subject_active off the join and _resolve_key asks self.subjects again (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 test test_the_keys_own_join_can_still_refuse_it_with_no_subjects_store_wired still passes against.
  • 2026-09-12 (measured at cd2c248): whoami reports federation_enabled: false as a literal (auth.py 178), and current_identity passes introspector=None (130), so every Labs Bearer that decodes is 503 with Retry-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_uses is a 403 key_exhausted on the read after the last permitted one, and note_use increments after check_key, so a key with max_uses = 1 serves exactly one request. Labs' legacy security service deactivates the key permanently on the same condition (api_key_security_service.py 72 to 77); Prima leaves it active and exhausted, which a higher max_uses reopens.
  • 2026-09-12 (measured at cd2c248): the wildcard tightening is real for migrated keys. keys.py 34 to 44 asks for an audit of Labs api_keys for {"admin", "full", "*"} before any backfill; a key that relied on the wildcard in Labs works on Prima only if rewritten to ['ai'], which scripts/backfill_api_key.py forces and the seam refuses with 400 unrepresentable_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 with self_issuer=settings.public_base_url and subjects (209 to 214).
    • prima/errors.py: GATEWAY_PREFIX (23), PrimaError.body (71 to 80), authentication_error (108 to 111).
    • prima/api/v1.py: CurrentIdentity imported at 94 and on the handlers (173 list_models, 423, 921, 1092 usage, the route the F8 line above depends on), the tenant read in the completion pipeline (256, 271 to 274).
    • prima/runtime/jobs/identity.py 39 aliases current_identity as resolve_job_identity; prima/runtime/jobs/router.py mounts on GATEWAY_PREFIX (124) and declares it on the two /jobs handlers (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_keys 142 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.md rows 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.

  1. 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="" prints 45 passed; python -m pytest tests/auth -q -o addopts="" prints 232 passed, 21 skipped.
  2. 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'))" prints 38e293003c756b7d6625c6b33e53b49d1a58ccef3ae8b89f5c9951f6c910b419 then True False; python -c "from prima.auth.keys import looks_like_prima_key; print(looks_like_prima_key('x'*20))" prints True (the shape check has no prefix test; see Invariants and Gotchas).
  3. 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',)))" prints True False False.
  4. curl -s -H "X-API-Key: $K" $PRIMA_BASE/api/prima/v1/whoami prints a JSON object with "auth_method":"api_key", "aud":null, "surfaces":[], "scopes":["ai"], "degraded":false and "federation_enabled":false; no key in the output contains an @.
  5. curl -s -H "Authorization: Bearer $K" $PRIMA_BASE/api/prima/v1/whoami prints 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/whoami prints the same object (the junk-Bearer fallback).
  6. curl -s -i -H "X-API-Key: prima_pk_$(python -c "import secrets; print(secrets.token_urlsafe(32))")" $PRIMA_BASE/api/prima/v1/whoami shows 401 and 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/whoami with no credential shows 401 with "message":"Missing credentials." and the same code.
  7. psql -c "UPDATE prima_api_keys SET scopes = ARRAY['*']::varchar[] WHERE key_hash = :h" fails with ck_prima_api_keys_no_wildcard; psql -c "UPDATE prima_api_keys SET scopes = ARRAY[]::varchar[] ..." fails with ck_prima_api_keys_scopes_nonempty; psql -c "UPDATE prima_api_keys SET scopes = ARRAY['read']::varchar[] WHERE key_hash = :h" succeeds and the next whoami with $K is 403 with "code":"insufficient_scope"; restore ARRAY['ai'].
  8. python -m scripts.suspend_subject --subject-id <the subject of $K> prints a result with status_changed=True; the next whoami with $K is 401 invalid_api_key, byte for byte the body from check 6; running the script again prints status_changed=False; psql -c "UPDATE prima_subjects SET status = 'active' WHERE id = ..." and the key works again.
  9. 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"; set max_uses = NULL.
  10. grep -rnE '^(from|import) .*labs_internal' prima/auth/keys.py prima/seams/identity.py prima/api/auth.py prima/auth/repository.py | wc -l prints 0 (the unanchored grep -rn "labs_internal" over the same files prints 5, all docstring and comment mentions: keys.py 10, identity.py 16, 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.py prints 1 (the comment that says it is not read) and grep -c 'headers.get("X-Forwarded' prima/api/auth.py prints 0.
  11. (Legacy copy, from alchemy_labs/backend at fd8558f with the :5434 Postgres.) python -m pytest tests/test_primaapi_identity.py -q --no-cov -o addopts="" -p no:cacheprovider prints 10 passed; with the app running, curl -s -i -H "Authorization: Bearer $LABS_AI_KEY" http://127.0.0.1:8000/api/prima/v1/models shows 401 (the fallback Prima has and this copy lacks) and adding -H "X-API-Key: $LABS_AI_KEY" shows 200.

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.