Module kit / Identity and auth
Surface registry and gate
identity-auth/surface-registry-and-gate
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | yes |
| Stack | fastapi, sqlalchemy-sync, postgres |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/alembic-migrations, _shared/test-layout |
| Frontends | identity-auth/ui/auth-screens, identity-auth/ui/admin-users-console, identity-auth/ui/chat-signin-and-workspace-picker, identity-auth/ui/developer-portal-auth-pages |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Version | 1 |
Purpose
A surface is a front door (the Labs app, prima.li, the chat, the developer portal), and whether a person may walk through one is a row in user_surface_grants, never a role, never a claim, never the fact that a users row exists. The invariant that makes it trustworthy is structural: mount_router will not register a router without a surfaces= decision, assert_every_route_gated refuses to boot the process if one slipped past, the gate reads the grant table live on every credentialed request, and adding a surface to the registry grants nobody anything because a new surface has zero rows on the day it ships.
This recipe covers the registry (core/surfaces.py: five ids, of which four are issuable and one is listed so that nobody adds it by hand), the gate attached at mount time (api/surface_gate.py), the one module allowed to read or write the grant table (services/surface_service.py), the per-request surface resolver for sign-in ceremonies (api/dependencies/surface.py), the relying-party and cookie attributes each surface carries, and the migration that created the table and backfilled every existing account. What the token's aud claim contains is B2 and B3; the dependency that turns a credential into a User is S5.
When to use
Include it from every recipe that mounts a router, mints a session, or asks "may this account be here". Read it before adding a surface, before mounting a router, before writing surface_exempt, and when the question is "the token is valid but this route says 403", "the API key works on one host and not the other", "why is role not enough", "which RP id does this passkey bind to", "why did my public router refuse a logged-in user". Trigger phrases: "SurfaceGate", "mount_router", "surface_exempt", "LABS_SURFACES", "ALL_SURFACES", "user_surface_grants", "grant_surfaces", "rp_id", "cookie_domain", "X-Alchemy-Surface", "primapvs".
Do not use it for the workspace a request acts on (S6: X-Workspace-ID, memberships), for the one admin write that touches the table (B16's POST /api/admin/create-user, admin_router.py 1523 to 1526, which writes LABS_SIGNUP_SURFACES with granted_by = the admin for the account it creates; no route grants or revokes a single surface at fd8558f, Gotchas), for API-key scopes and the prefix lock (B10, S5), or for the audience rule the standalone PrimaAPI enforces (prima/auth/surfaces.py at cd2c248, the D3 reference for gateway auth, B11; Labs' core/surfaces.py is the Labs-side registry and the two vocabularies are kept equal by test_labs_and_prima_agree_on_the_surface_vocabulary). PrimaOS (primapvs) has its own identity system and is out (D6); it appears in the registry only as external=True.
Stack assumptions
Measured at fd8558f (2026-09-12): fastapi==0.118.2 (Depends(<instance>) on include_router, HTTPConnection so one dependency covers HTTP and WebSocket routes), sqlalchemy==2.0.43 sync with raw text() for the grant table, Postgres 15 for the CHECK constraint and ON CONFLICT DO NOTHING. Foundations assumed: S4 (every router goes through mount_router; assert_every_route_gated is the last statement of app_fastapi.py), S5 (the credential order the gate copies: Bearer, then the access_token cookie, then X-API-Key; the uniform 401 the endpoint produces), S7 (the 403 and 503 bodies), S8 (surface_grants_001), S9.
Environment names and fail behaviour (core/config.py):
| Name | Default | Behaviour |
|---|---|---|
FRONTEND_URL | http://localhost:3000 (43) | added to the Labs surface's WebAuthn origins and used as the Labs link base (core/surfaces.py 117 to 118, 337 to 338) |
PRIMA_EXTRA_ORIGINS | empty list (151) | comma-separated extra origins for the three Prima surfaces (previews, localhost), read by origins and all_surface_origins (300 to 312, 353 to 364); seeds CORS |
WEBAUTHN_RP_ID_BACKFILL | unset | the explicit opt-in migration 20260803_0001_webauthn_rp_id.py (B7) honours as given (17 to 21, _backfill_rp_id 70 onward); unset, the migration falls back to WEBAUTHN_RP_ID and then settings.WEBAUTHN_RP_ID and refuses an inferred placeholder (_NOT_A_REAL_RP, 67), which is why the harness sets this variable rather than the other (RENDERING.md, "Alchemy Labs") |
JWT_LEGACY_NO_AUD_UNTIL | empty | a token with no aud is read as labs only inside the window; the gate sees it through decode_token_with_surface (B2) |
There is no setting that adds a surface: the registry is a module-level literal (SURFACES, 125 to 166) and reload_registry is a documented no-op (367 to 371). A new surface is a code change, a migration that widens ck_usg_surface, and grant rows.
Data model
-- migrations/versions/20260802_0002_surface_grants.py 52 to 70; models/user_surface_grant_model.py 37 to 63
user_surface_grants (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
surface varchar(32) NOT NULL,
granted_at timestamp NOT NULL DEFAULT now(),
granted_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (user_id, surface),
CONSTRAINT ck_usg_surface CHECK (surface IN ('labs', 'prima_web', 'prima_chat', 'prima_dev')) -- 'primapvs' absent on purpose
);
CREATE INDEX ix_user_surface_grants_user_id ON user_surface_grants(user_id);
-- the same migration, 102 to 117: the label gets a domain and stays a label
ALTER TABLE users ADD CONSTRAINT ck_users_role
CHECK (role IN ('user', 'admin', 'designer', 'delivery', 'api_only', 'medical_reviewer'));
-- 72 to 80, owned by B5: a link opens exactly one door, recorded on the row
ALTER TABLE magic_links ADD COLUMN surface varchar(32) NOT NULL DEFAULT 'labs';
The model's CHECK is built from ISSUABLE_SURFACES at import (_SURFACE_IN_LIST, user_surface_grant_model.py 32 to 34) and named with conv("ck_usg_surface") so create_all() and the migration produce the same name (56 to 61); the migration keeps its list literal (34 to 37) because a migration describes the schema at its point in history. The backfill (119 to 142): every existing user gets the three Prima surfaces; every user whose role IS DISTINCT FROM 'api_only' gets labs. Nobody lost access on the day it ran; api_only accounts gained nothing they did not have by design.
The registry, one frozen dataclass per surface (core/surfaces.py 41 to 166):
| id | hosts | webauthn_rp_id (rp_id) | rp_name | cookie_domain | origin | external |
|---|---|---|---|---|---|---|
labs | the Labs host | the Labs host (must never be shortened: an RP id change invalidates every enrolled passkey, 129 to 130) | Alchemy Labs | the Labs host | the Labs origin | no |
prima_web | prima.li, www.prima.li | prima.li | Prima | .prima.li | https://prima.li | no |
prima_chat | chat.prima.li | prima.li (a registrable suffix, so one credential covers all three, 145 to 147) | Prima | .prima.li | https://chat.prima.li | no |
prima_dev | developer.prima.li | prima.li | Prima | .prima.li | https://developer.prima.li | no |
primapvs | none | none | Prima | none | none | yes |
Derived sets (169 to 189): ISSUABLE_SURFACES (the four), PRIMA_SURFACES (three), LABS_SURFACES ({labs}), ALL_SURFACES (= ISSUABLE_SURFACES), LABS_SIGNUP_SURFACES (labs plus the three), API_ONLY_SURFACES (= PRIMA_SURFACES). origins (88 to 122) is the canonical origin first, then FRONTEND_URL for labs or PRIMA_EXTRA_ORIGINS for the others, trailing slashes stripped, duplicates dropped. webauthn_rp_ids() (342 to 350) is two values in production.
What is not stored: a surface on the sessions row (it is the token's aud); a surface on api_keys (a key is not surface-bound; its owner's grants decide, gate 174 to 187); a permission on users.role (a label with a CHECK, read for surface access by nothing; test_role_column_now_has_a_domain 193); a primapvs row (refused by the CHECK, by assert_issuable, and by grant_surfaces).
Request state written by the gate (surface_gate.py 197 to 198): request.state.surface (the token's aud, or for an API key the lowest-sorted granted surface the router opens) and request.state.surface_grants (the set as read). S5's dependency writes request.state.surface again from the token (api/dependencies.py 214, and again at 404 for OptionalUser), so a handler sees the same value on the JWT path and the gate's choice on the key path.
API surface
This recipe owns no route. It owns a dependency on every route, a boot assertion, and the sentences a caller can key on:
| Where | Auth dep | Tenancy | Codes | Detail strings |
|---|---|---|---|---|
every router mounted with surfaces=<set> | SurfaceGate(<set>) before the endpoint's own dependencies | none (the gate reads the user, not the workspace) | pass (anonymous, or an undecodable token, or an unknown key: the endpoint answers); 403; 503; on a WebSocket handshake a close with code 1008 and the same sentence (_deny, 82 to 91) | 403 "This session belongs to the '<surface>' surface and cannot be used here." (142 to 152); 403 "This account is not permitted on this surface." (177 to 186 for a key owner with no usable grant, 188 to 195 for a token whose aud is not granted); 503 "Access control is temporarily unavailable." (163 to 172) |
every router mounted surface_exempt("public", note) | a SurfaceGate over all four issuable surfaces, delegated (240 to 252) | none | the same as above for a presented credential; pass without one | the same sentences |
every router mounted surface_exempt("own_auth", note) | none (a true no-op, 247 to 249) | the router's own mechanism | none from this layer | none |
GET /api/auth/me (B2) | ActiveUser | none | 200 with surfaces: sorted(get_user_surfaces_standalone(user.id)) | the frontend's authorisation contract (auth_router.py 697 to 715) |
sign-in routes that take a surface (B5 request-magic-link, B7 webauthn/*) | none | none | 400 on an unknown, external or conflicting surface | "Unknown surface '<s>'" (auth_router._requested_surface, 155 to 173); "Unknown surface. Expected one of: labs, prima_web, prima_chat, prima_dev", "That surface is external; Labs does not issue sessions for it.", "Requested surface does not match the request origin." (api/dependencies/surface.py 66 to 83) |
The mount table at fd8558f (app_fastapi.py 819 to 1078; counted 2026-09-12 in the source): 93 mount_router(...) calls, of which 74 LABS_SURFACES, 6 ALL_SURFACES (auth_router 820, auth_infomaniak_router 821, webauthn_router 822, developer_router 827, and the legacy in-Labs gateway copy primaapi_router 892 and primalayered_router 907, which a rebuild replaces with the standalone service, D3), 10 surface_exempt("public", ...) (health 819, webhooks 882, Prima inbound 887, form protection 899, public forms 944, the two tracking beacons 951 to 952, published one-pagers 1006 to 1008, JWKS 1052 to 1060, clean URLs 1077) and 3 surface_exempt("own_auth", ...) (WebSocket 880, Airiel 1022, Aurum 1034); plus 4 routes declared directly on app with dependencies=[Depends(surface_exempt("public", ...))] (1081 to 1154: outreach redirect, service banner, two probes). Some mounts sit under feature flags, so the live table is smaller: importing the app with the development defaults on 2026-09-12 gave 981 API routes, 920 gated (872 labs only, 48 all four surfaces), 59 exempt public, 2 exempt own_auth, 0 ungated; the wiki counted 891 routes on 2026-08-02.
Application logic
SurfaceGate.__call__(conn) (surface_gate.py 108 to 199), in order:
- Read the credential the way S5 does:
Authorization: Bearer <t>, else theaccess_tokencookie (_extract_token, 74 to 79); only when neither is present,X-API-Key(114). No credential: return (117 to 118). The gate is authorisation, not authentication; the endpoint's dependency answers the 401. - With a token:
auth_service.decode_token_with_surface(token, token_type="access")(B2). AnyJWTErrorreturns without a decision (128 to 133), so an unreadable or expired token produces the endpoint's 401 and never a 403 that would reveal which routers exist. A missing or non-UUIDsubreturns the same way (134 to 140). A surface outsideself.allowedis the first 403 with a WARNING naming path, surface and the router's set (142 to 152). - Open a database session. With a key and no token:
_api_key_user_id(db, value)hashes the value with SHA-256 and selectsuser_idfromapi_keys WHERE key_hash = :h AND is_active = TRUE(94 to 104);Nonereturns without a decision (158 to 161). Thenget_user_surfaces(db, user_id);SurfaceGrantErroris the 503 (163 to 172). - Key path:
granted & self.allowedempty is the second 403; otherwisesorted(usable)[0]becomes the surface (174 to 187). Token path:surface not in grantedis the same 403 (188 to 195). conn.state.surfaceandconn.state.surface_grantsare set (197 to 198).
SurfaceExempt(reason, note) (202 to 252): reason must be public or own_auth, else ValueError at import; public builds a delegate SurfaceGate((LABS, PRIMA_WEB, PRIMA_CHAT, PRIMA_DEV)) and calls it, so "no session required" never means "any session accepted" (the comment at 226 to 239 records the 12 mounts that were no-ops before the 2026-08-02 audit); own_auth is a true no-op for routers with their own prefix-bound mechanism.
mount_router(app, router, *, surfaces, **kwargs) (265 to 274): surfaces is a required keyword; a SurfaceExempt is used as is, anything else becomes SurfaceGate(surfaces) (which runs validate_surfaces, 223 to 235: the empty set and any external or unknown id raise at import); the marker is appended to the router's dependencies and app.include_router is called. ungated_routes(app) (286 to 297) walks every APIRoute and APIWebSocketRoute outside _FRAMEWORK_PATHS (54 to 60: OpenAPI, docs, redoc, metrics) and lists those with no marker; assert_every_route_gated(app) (300 to 316) raises RuntimeError naming them, or logs ADR-0020 surface gate: all N routes carry a surface decision. It is the last statement of app_fastapi.py (1226 to 1234).
surface_service (33 to 132): get_user_surfaces(db, user_id) is SELECT surface FROM user_surface_grants WHERE user_id = :uid, filtered through ISSUABLE_SURFACES so a row for a retired surface re-opens nothing (48 to 50), with every exception re-raised as SurfaceGrantError (40 to 47); user_has_surface (53 to 57); grant_surfaces(db, user_id, surfaces, *, granted_by=None, commit=False) asserts each surface issuable, inserts with ON CONFLICT (user_id, surface) DO NOTHING in sorted order, commits only when asked (60 to 92); revoke_surfaces deletes by ANY(:surfaces) (95 to 111); the two _standalone variants open their own session (114 to 132). Callers of grant_surfaces outside tests/ at fd8558f, counted 2026-09-12 (grep -rn grant_surfaces --include='*.py' backend), ten in all: auth_service.create_user (B1, 647: LABS_SIGNUP_SURFACES, or API_ONLY_SURFACES when the row says role == "api_only"); the API-only signup (B14, services/primaapi/identity.py 319, API_ONLY_SURFACES, in the user row's own transaction); POST /api/admin/create-user (B16, admin_router.py 1523 to 1526, LABS_SIGNUP_SURFACES, the only caller that passes granted_by); the Infomaniak OIDC callback when it provisions a new account (B8, auth_infomaniak_router.py 101, the surface the login resolved to); the reviewer invite (content_review_service.py 127, {labs} only); the two client-onboarding completions (client_onboarding_service.py 935 and 1066, LABS_SIGNUP_SURFACES); the desk domain sign-in and its account provisioner (services/service_desk/signin.py 364, services/service_desk/access.py 659, {labs}); heal_shared_account (access.py 363, {labs}), which re-grants on a login request to an account that already exists; and the repair script (scripts/issue_magic_links.py 129, --repair-grants, API_ONLY_SURFACES). Plus the migration's backfill. revoke_surfaces has no caller outside tests/ at fd8558f: nothing in production takes a surface away (Gotchas).
resolve_surface(request, explicit=None) (api/dependencies/surface.py 57 to 89), for ceremonies that must know which door they serve before a token exists: an explicit id from the argument, the X-Alchemy-Surface header or ?surface= wins whenever one is present (61 to 84: unknown is 400, external is 400, and an explicit id that disagrees with a recognised Origin or Referer host is 400 with a WARNING); else the surface the browser's Origin resolves to (exact match against each surface's origin, then its host) or the Referer host (46 to 54); else labs (DEFAULT_SURFACE, core/surfaces.py 262). auth_router._requested_surface (155 to 173) is the older sibling for magic links: explicit id, then surface_for_host(Host), then labs. Both produce a request, never a grant: every caller re-checks the answer against the grant table inside create_session or create_magic_link.
Invariants
- Every product route carries a surface decision or the process does not start (
assert_every_route_gated;test_every_route_carries_a_surface_decision680;test_a_router_mounted_without_a_gate_fails_the_boot_assertion688;test_mount_router_makes_the_surface_decision_unforgettable707, theTypeErroron a missingsurfaces=;test_no_router_is_mounted_with_raw_include_router762). - No row, no access:
test_a_user_with_no_grants_is_allowed_nowhere(181); the gate,create_session,refresh_sessionandcreate_magic_linkall read the table and none readsusers.rolefor the question. user_surface_grantsis read in exactly one module (test_the_grants_table_is_read_in_exactly_one_module716) and every read failure is a deny, never an empty set (surface_service.py34 to 38).primapvsis never issuable, never grantable, never gated:assert_issuableraises (211 to 220),grant_surfacesraises (74), the CHECK refuses the row (test_database_refuses_an_unknown_surface_row165),validate_surfacesrefuses it in a gate (test_primapvs_is_never_issuable144,test_grant_writer_refuses_primapvs157,test_public_gate_admits_every_issuable_surface_and_not_primapvs).- The gate only removes access: an anonymous request, an undecodable token and an unknown key pass through to the endpoint's own 401 (
test_beacon_with_no_credential_is_allowed); the gate never mints, never grants, never writes. - A
publicexemption checks a presented credential against all four surfaces;own_authchecks nothing (test_public_delegates_to_a_real_gate,test_own_auth_stays_a_true_noop); no third reason exists (test_exempt_reasons_remain_closed). - An API-only session (
audin the Prima surfaces, nolabsrow) is 403 on everyLABS_SURFACESroute and admitted on the gateway (test_api_only_session_is_denied_on_every_labs_gated_route354, which walks the real route table;test_api_only_session_403s_over_real_http400;test_an_api_only_session_is_accepted_on_the_prima_gateway663); a Labs user may still use the gateway (test_a_labs_user_may_still_use_the_prima_gateway638). - Grants are read live: deleting a row takes effect on the next gated request, restoring it takes effect without a new token (S5 acceptance check 5, repeated below).
rp_id,rp_nameandoriginsare readable on every surface without raising, the three Prima surfaces share one RP id and one RP name, and the Labs RP id equals the enrolled Labs host (test_surface_rp_id.py, 40 cases).- The
HostandOriginheaders choose defaults (which origin a link points at, which RP id a ceremony binds to) and never decide access (surface_for_hostdocstring 239 to 243).
Frontend
identity-auth/ui/auth-screens PrivateRoute renders only when /me.surfaces contains the build's surface;
the 403 "not permitted to sign in" at login is the missing labs row;
"This account can't be used here" is hasSurface false (S2)
identity-auth/ui/admin-users-console no grants column at fd8558f: users-list returns no surfaces and no admin
route reads or writes one. The only grant write behind the page is
POST /create-user, which writes LABS_SIGNUP_SURFACES with granted_by =
the admin for the account it creates (Gotchas)
identity-auth/ui/chat-signin-and-workspace-picker
the chat server asks for a prima_chat magic link (surface in the body)
and the gate admits the resulting session on ALL_SURFACES routers only
identity-auth/ui/developer-portal-auth-pages
the portal asks for prima_dev; its session mints ai-only keys on
developer_router and is 403 on every LABS_SURFACES route
Minimum admin and debug surface: GET /api/auth/me (surfaces); the three gate WARNING lines (surface gate: <surface> session denied on <path> (router allows [...]), surface gate: api key owner <id> denied on <path> (grants=[...], allows=[...]), surface gate: user <id> has no grant for <surface> (grants=[...]), 143 to 148, 179 to 185, 189 to 194); the boot line ADR-0020 surface gate: all N routes carry a surface decision (316); SELECT surface, granted_at, granted_by FROM user_surface_grants WHERE user_id = :u; the repair script scripts/issue_magic_links.py --check --email <addr> --surface labs and --repair-grants (wiki platform/architecture.md "silent lockout", 6cce08b; not re-measured here).
Security & tenancy
Auth dependency: the gate runs before the endpoint's dependency on every mounted router; the endpoint's dependency (S5) then authenticates. Workspace derivation: none here; S6 reads request.state this layer writes. Fail direction, per seam: the grant read closed (503 when the table cannot be read, in the gate; SurfaceAccessDenied at mint); the boot assertion closed (no decision, no process); the surface resolver closed on conflict (400); an undecodable credential deliberately undecided (the endpoint's 401, so the gate leaks no router inventory); own_auth open by declaration, for two routers with their own prefix lock.
Credentials at rest: none owned here; granted_by is an audit column, nullable, SET NULL when the admin is deleted. Rate limits: none on the gate (one indexed SELECT per credentialed request, plus the token decode S5 repeats); the sign-in routes that take a surface carry B18's limiter.
Attacker-controlled inputs: the token's aud (a string, checked against ISSUABLE_SURFACES in resolve_token_surface, a list refused, B2), X-API-Key (hashed before the lookup, is_active in the query), Host (a default for links and RP ids, spoofable and harmless because the grant decides), Origin and Referer (exact origin match, then host; used to refuse a conflicting explicit id), X-Alchemy-Surface, ?surface= and the body's surface (validated against the registry, external refused). None of them can add a surface to a session; the only thing that can is a grant row, written by one of the ten callers listed in Application logic, each behind its own authentication. Seven of them write at account creation; the other three write labs onto an account that already exists (the desk sign-in and heal_shared_account, after the domain rule matched the caller's mailbox; the repair script, from an operator's shell).
Tenancy of the decision: per user and per surface, global across workspaces (a grant is not a membership); a token belongs to one surface for its life; an API key belongs to its owner's grants and the router's set, and request.state.surface on the key path is the lowest-sorted intersection, which is labs whenever the owner holds it (Gotchas). The standalone PrimaAPI, the D3 reference for gateway auth (prima/auth/surfaces.py at cd2c248: ISSUABLE_SURFACES 75, PRIMA_SURFACES 80, GATEWAY_SURFACES 83 every issuable surface, MANAGEMENT_SURFACES 86 prima_dev only, enforce_audience 93), narrows aud on the key-management routes and nowhere else; the in-Labs mount at app_fastapi.py 892 is the legacy copy of that gateway and is not what a rebuild follows.
Tests
Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f with python -m pytest tests/test_surface_scoped_access.py tests/test_public_exemption_checks_credentials.py tests/test_surface_rp_id.py -q --no-cov -o addopts="" -p no:cacheprovider): 89 passed in 7.33s (44, 5 and 40 collected).
test_surface_scoped_access.py: the registry (144 to 205), the mint and decode rules (209 to 305), the API-only account (308 to 346), the route-table walk and the real-HTTP parametrisation (354 to 436), the self-service key rule (440), magic links and password reset against the grant (471 to 540), login and session (544 to 577), the Prima signup (579 to 620), Spark's relabel refusal (622), the gateway from both sides (638 to 678), the boot assertion and the one-module rule (680 to 771).test_the_grants_table_is_read_in_exactly_one_module(716) is the authoritative form of the invariant and greps the tree.test_public_exemption_checks_credentials.py(5 tests, no database):test_public_delegates_to_a_real_gate(24),test_own_auth_stays_a_true_noop(28),test_beacon_with_no_credential_is_allowed(36),test_public_gate_admits_every_issuable_surface_and_not_primapvs(41),test_exempt_reasons_remain_closed(49).test_surface_rp_id.py(40 tests, no database, parametrised over the four issuable surfaces):rp_idequalswebauthn_rp_idand is readable (28, 34), the Labs RP id is the enrolled host (39), the alias tracks the field and survivesNone(48, 60),rp_namereadable, non-empty, the product name, shared across Prima (81 to 100),originsa tuple of strings with no trailing slash starting with the canonical origin and includingFRONTEND_URLfor Labs (103 to 121).test_prima_ed25519_interop.py::test_labs_and_prima_agree_on_the_surface_vocabulary(698): the two registries name the same ids.- Known-answer expectations for a rebuilt service:
sorted(ISSUABLE_SURFACES) == ['labs', 'prima_chat', 'prima_dev', 'prima_web'];SurfaceGate([])raisesValueError("A surface gate must allow at least one surface.");SurfaceGate(["primapvs"])raisesSurfaceNotIssuable;surface_exempt("because")raisesValueError;mount_router(app, router)withoutsurfaces=raisesTypeError;INSERT INTO user_surface_grants VALUES (<uid>, 'primapvs', now(), NULL)violatesck_usg_surface.
Hermetic versus live: all hermetic; the route-table walk uses the real app object with the test database. Playwright gate: the F1 spec's "not permitted" screen (a session for a surface the build does not serve) is the browser-visible form of the second 403.
Deploy & ops
Config keys per app heredoc (S13): FRONTEND_URL, PRIMA_EXTRA_ORIGINS (each preview origin that must complete a Prima passkey ceremony or pass CORS), WEBAUTHN_RP_ID_BACKFILL once for the B7 migration. Nothing else; the registry is code.
Migrations order: surface_grants_001 (20260802_0002_surface_grants.py, revises primaapi_v1_001) creates the table, the role CHECK, magic_links.surface, prima_signup_intents, and runs the backfill in the same upgrade; it must run before any build that calls grant_surfaces at account creation (B1, B14) and before the gate is mounted, because a missing table is a SurfaceGrantError on every credentialed request, which the gate turns into 503 everywhere. The create_all() path (pytest only, init_db() checks for pytest) builds the table from the model with the same constraint name (conv("ck_usg_surface")). Adding a surface: a code change to SURFACES, a new migration that drops and recreates ck_usg_surface with the widened list (the model's CHECK widens itself from ISSUABLE_SURFACES; the migration's literal does not), a deploy, and then grant rows; the surface is closed until the rows exist.
Scheduler jobs: none. Argus checks: the boot log carries ADR-0020 surface gate: all N routes carry a surface decision with N equal to the previous deploy's plus the routes the release added; GET /api/auth/me for the synthetic owner returns surfaces containing labs; a request with a prima_web token on any LABS_SURFACES route returns 403 with the first sentence; SELECT count(*) FROM users u WHERE NOT EXISTS (SELECT 1 FROM user_surface_grants g WHERE g.user_id = u.id) is 0 outside a signup in flight (a non-zero count is the B1 two-commit gap or a failed backfill). Rollback: the migration's downgrade (145 to 164) drops the table and the CHECKs; a code rollback to before the gate leaves the table in place and unread, which is safe; a rollback of the code without the table is the 503-everywhere case above.
Gotchas
- 2026-09-12 (measured at fd8558f; recorded under D8, no code changed in this wave): nothing in the API grants or revokes a single surface for another account.
revoke_surfaces(services/surface_service.py95 to 111) has no caller outsidetests/, and the only admin write isPOST /api/admin/create-user, which writesLABS_SIGNUP_SURFACESfor the account it is creating (admin_router.py1523 to 1526). The refusal onPATCH /users/{id}/roleeven names the missing tool: anapi_onlyaccount cannot be relabelled and the sentence is "To give this person Labs access, grant the 'labs' surface." (995 to 1002), which no route can do. Today that leaves SQL, orscripts/issue_magic_links.py --repair-grants(129), which writesAPI_ONLY_SURFACESand nothing else. The F4 console therefore shows no grants column and has no field to render (ui/admin-users-console.mdandadmin-user-management-and-impersonation.mdGotchas, both measured at fd8558f). Fix: a grant route and a revoke route underAdminUser, each writinggranted_byand its own audit type,surfaceson theusers-listrow fromget_user_surfaces_standalone, and the column on F4. - 2026-09-12 (measured at fd8558f):
api/surface_gate.pydeliberately has nofrom __future__ import annotations(31 to 37).SurfaceGateis used asDepends(<instance>), and FastAPI resolves a callable's annotations againstcall.__globals__, which an instance lacks; with postponed evaluation theHTTPConnectionannotation arrives as a string, FastAPI treats it as a query parameter, and every gated route answers 422. A formatter that adds the import to every file breaks every route at once. Fix: keep the comment, and a test that greps the module for the import;test_api_only_session_403s_over_real_http(400 to 421) is the one that would turn red today, as a 422 instead of a 403. - 2026-09-12 (measured at fd8558f): on the API-key path the gate sets
request.state.surface = sorted(granted & allowed)[0](187).labssorts beforeprima_chat,prima_devandprima_web, so a key whose owner holds alabsgrant is stampedlabson anALL_SURFACESrouter (the gateway,developer_router) even when the call comes from a Prima host. One handler reads that label and decides on it:POST /api/developer/keysforces a mint down to theaiscope whenrequest.state.surfaceis inPRIMA_SURFACES(developer_router.py113 to 127), and the route is reachable with anX-API-Keybecauseget_current_useraccepts one (api/dependencies.py244 to 264) anddeveloper_routeris mountedALL_SURFACES(app_fastapi.py827). A mint made with a key therefore takes the unrestricted branch whenever the owner holdslabs, whatever host called. That is the same outcome the same owner gets from alabssession, so the defect is in the label and in the scope rule that trusts it, not in access; anai-scoped key is held to/api/prima/v1/by the prefix lock either way (api/dependencies.py81 to 101). Fix: prefer the surface theOriginresolves to when it is in the intersection, else the sorted first. - 2026-09-12 (measured at fd8558f):
surface_exempt("public")did no checking on 12 mounts until the 2026-08-02 audit (the comment at 226 to 239; wikioperations/security-audit-2026-08-02.mdB1). It now delegates to a four-surface gate, so a public router can refuse a credential: a token minted for a surface its owner is not granted is 403 on a beacon path. That is correct; it surprises the client that sends a stale cookie with a tracking beacon. Fix on the client: never send credentials to a beacon. - 2026-09-12 (measured at fd8558f): the grant read is one
SELECTper credentialed request in a session the gate opens itself (get_db_context(), 154), in addition to the request's own session;get_current_userthen does not read grants again, butGET /api/auth/me,/loginandregistereach read them once more throughget_user_surfaces_standalone. Per request that is two checkouts from the syncQueuePool(core/database.py50 to 58:pool_size=2,max_overflow=3,pool_recycle=300; only the async engine still usesNullPool, 48 to 49), so every credentialed request doubles its pool pressure. Fix: read once in the gate and let S5 and the handlers userequest.state.surface_grants. - 2026-09-12 (measured at fd8558f; the aliases landed in 25d08bd 2026-08-29
rp_id, e803698 2026-09-04rp_name, 20d2e46 2026-09-04origins):Surfacedeclaredwebauthn_rp_idand the WebAuthn service readrp_id,rp_nameandorigins; each alias was added after a live 500 (53 to 122: login repaired 2026-08-29, registration 500 until 2026-09-04, thenorigins).test_surface_rp_id.pynow covers all three on every surface. When a fix adds one attribute, grep every attribute the caller reads. - 2026-09-12 (measured at fd8558f): the migration's surface list is literal (
_GRANTABLE_SURFACES, 34 to 37) and the model's CHECK is built fromISSUABLE_SURFACES(user_surface_grant_model.py32 to 34). Adding a surface to the registry without a migration makes acreate_all()test database accept a row the production database refuses; the tests pass and the first grant in production fails onck_usg_surface. Fix: the migration that widens the CHECK ships in the same change as the registry entry;test_database_refuses_an_unknown_surface_row(165) is the test to extend with the new id. - 2026-09-12 (measured at fd8558f):
_requested_surfaceinauth_router.py(155 to 173) andresolve_surfaceinapi/dependencies/surface.py(57 to 89) are two resolvers with two precedence orders (explicit, thenHost, thenlabs; explicit, thenOriginorReferer, thenlabs, with a conflict check only in the second, which refuses an explicit id that disagrees with a recognisedOrigin). A magic link asked for with an explicitsurfacethat disagrees with the browser'sOriginis accepted by the first and refused by the second. Neither grants anything, so the difference is which door a link points at. Fix: one resolver, the second one, called by both. - 2026-09-12 (measured at fd8558f): the gate's key lookup (
_api_key_user_id, 94 to 104) checksis_activeand nothing else; expiry, suspension, IP allow-list and use count are S5'scheck_api_key_securityin the endpoint's dependency. A suspended key therefore passes the gate and fails the dependency, which is the intended order (the gate never grants), but a reader of the gate's WARNING lines will not see suspended keys there. - 2026-09-12 (measured at fd8558f): the mount table counts differ by environment because several mounts are flag-gated (
SPARK_ENABLED985 to 997,AIRIEL_ENABLED1022,AURUM_ENABLED1034, the optional feature imports at 919 to 980). The boot line'sNis therefore per environment, and the wiki's 891 (2026-08-02) is neither the source count nor today's development count (981 API routes on 2026-09-12).Nis also not the route count this recipe measures:assert_every_route_gatedlogslen(app.routes)(316), which counts every registered route object, the framework paths and any static mount included, while the 981 above countsAPIRouteandAPIWebSocketRouteonly. CompareNagainst the previous deploy of the same environment, never against a count taken another way. - 2026-09-12 (measured at fd8558f):
users.rolenow has a CHECK (ck_users_role, six values includingmedical_reviewer, which the ADR omitted andcontent_review_service.pyhad been writing;user_model.py28 to 42). The migration normalises stray values touserbefore adding the constraint (102 to 111). A new service copies the CHECK and the rule that the column is never read for surface access. - 2026-09-12 (measured at fd8558f, code wins over the wiki):
platform/architecture.md(6cce08b) "Surfaces" is accurate about the model; its "All 891 routes" is a 2026-08-02 count. The scout's note that "gate is authz-only (anonymous passes, endpoint 401s)" is what the code does (116 to 118, 128 to 133).
Reference implementation
Paths are relative to the repo root; the commit is measured_on.
alchemy_labs(branchd1a-token-sourceat fd8558f):backend/core/surfaces.py: the docstring (1 to 27), the ids (34 to 38),Surfacewithrp_id,rp_name,origins(41 to 122),SURFACES(125 to 166), the derived sets (169 to 189),UnknownSurface,SurfaceNotIssuable(192 to 197),get_surface,is_issuable,assert_issuable,validate_surfaces(200 to 235),surface_for_host(238 to 250), the compatibility accessors (253 to 297),_extra_origins(300 to 312),surface_for_origin(315 to 324),link_base_for(327 to 339),webauthn_rp_ids(342 to 350),all_surface_origins(353 to 364),reload_registry(367 to 371).backend/api/surface_gate.py: the docstring and the annotations note (1 to 37),_FRAMEWORK_PATHS(54 to 60),SurfaceGate(63 to 199),SurfaceExempt(202 to 252),surface_gate,surface_exempt(255 to 260),mount_router(265 to 274),route_surface_marker(277 to 283),ungated_routes(286 to 297),assert_every_route_gated(300 to 316).backend/api/dependencies/surface.py:SURFACE_HEADER(43),surface_from_origin_headers(46 to 54),resolve_surface(57 to 89),request_surface(92 to 94).backend/services/surface_service.py:SurfaceGrantError(29 to 30),get_user_surfaces(33 to 50),user_has_surface(53 to 57),grant_surfaces(60 to 92),revoke_surfaces(95 to 111), the standalone pair (114 to 132).backend/models/user_surface_grant_model.py(1 to 66);backend/models/user_model.pyUSER_ROLESand the role CHECK (28 to 42, 137 to 155);backend/models/__init__.py(80 to 85).backend/app_fastapi.py: the imports (131 to 137), the mount table (819 to 1078), the four direct routes (1081 to 1154), the boot assertion (1226 to 1234).backend/api/routers/auth_router.py:_require_labs_grant(140 to 152),_requested_surface(155 to 173),/me(697 to 715).backend/migrations/versions/20260802_0002_surface_grants.py(1 to 164).backend/tests/test_surface_scoped_access.py,backend/tests/test_public_exemption_checks_credentials.py,backend/tests/test_surface_rp_id.py,backend/tests/test_prima_ed25519_interop.py(698).
prima(feature/per-model-pricing, cd2c248):prima/auth/surfaces.py(ISSUABLE_SURFACES75,PRIMA_SURFACES80,GATEWAY_SURFACES83,MANAGEMENT_SURFACES86,enforce_audience93 onward): the D3 reference for gateway audience enforcement, and the registry Labs' owncore/surfaces.pyis kept equal to.- Wiki
philosophers_stone(6cce08b):platform/architecture.md("Surfaces", the passkey saga, "silent lockout"),operations/security-audit-2026-08-02.md(B1),operations/decisions/0020-prima-identity-and-prepaid-credits.md(section 1),operations/prima-modules-inventory.md(row B4).
Deviations of the reference from this recipe, all dated 2026-09-12: no route that grants or revokes a single surface, so revoke_surfaces has no production caller and F4 has no grants column; the key path's labs-first surface label, which POST /api/developer/keys reads; two grant reads per request and a third in the auth handlers; two surface resolvers with different precedence; the literal migration list beside the derived model CHECK; the flag-dependent route count; the alias trio that arrived one live 500 at a time.
Acceptance checks
Run from alchemy_labs/backend at the commit in measured_on with the :5434 Postgres up and the app on $B, or from a new service built to this recipe.
python -m pytest tests/test_surface_scoped_access.py tests/test_public_exemption_checks_credentials.py tests/test_surface_rp_id.py -q --no-cov -o addopts="" -p no:cacheproviderprints89 passed.python -c "from core.surfaces import *; print(sorted(ISSUABLE_SURFACES), sorted(LABS_SURFACES), sorted(LABS_SIGNUP_SURFACES), sorted(API_ONLY_SURFACES), SURFACES['primapvs'].external, webauthn_rp_ids())"prints['labs', 'prima_chat', 'prima_dev', 'prima_web'] ['labs'] ['labs', 'prima_chat', 'prima_dev', 'prima_web'] ['prima_chat', 'prima_dev', 'prima_web'] True ('<the Labs host>', 'prima.li').python -c "from api.surface_gate import SurfaceGate; SurfaceGate([])"raisesValueErrorwithA surface gate must allow at least one surface.;python -c "from api.surface_gate import SurfaceGate; SurfaceGate(['primapvs'])"raisesSurfaceNotIssuable;python -c "from api.surface_gate import surface_exempt; surface_exempt('because')"raisesValueError.grep -n "include_router(" app_fastapi.py | grep -v "^[0-9]*:#" | wc -lprints0(the one match at fd8558f, line 831, is a commented-out mount; the only liveapp.include_routeris insidemount_router,api/surface_gate.pyline 274);grep -c "mount_router(" app_fastapi.pyprints93;tail -n 13 app_fastapi.py | grep -c assert_every_route_gatedprints1(the assertion is line 1234 of 1246; a new service prints its own mount count and the same1).grep -rln "user_surface_grants" --include='*.py' . | grep -v "tests/\|migrations/"prints nine files at fd8558f, and every hit outsideservices/surface_service.py(line 26,_TABLE) andmodels/user_surface_grant_model.py(line 40,__tablename__) is a comment or a docstring;grep -rn "FROM {_TABLE}\|INTO {_TABLE}" --include='*.py' . | grep -v tests/ | wc -lprints3, all inservices/surface_service.py(test_the_grants_table_is_read_in_exactly_one_moduleis the authoritative form).- Boot with a router mounted through raw
app.include_router(or runtest_a_router_mounted_without_a_gate_fails_the_boot_assertion): the process exits withRuntimeErrorbeginningADR-0020 §1: these routes were registered without a surface decision.and listing the route; a normal boot logsADR-0020 surface gate: all <N> routes carry a surface decision. - With a
labssession$T:curl -s -H "Authorization: Bearer $T" $B/api/auth/mereturns"surfaces":[...]containing"labs";DELETE FROM user_surface_grants WHERE user_id = <id> AND surface = 'labs'and repeat:403"This account is not permitted on this surface."; restore the row and the same token is200again with no new login. - With a
prima_websession (create_session(..., surface="prima_web")in a shell for a user with that grant):curl -s -i -H "Authorization: Bearer $T" $B/api/workspaces/is403"This session belongs to the 'prima_web' surface and cannot be used here."; the same token on$B/api/auth/meis200(anALL_SURFACESrouter); the same token on apublicmount such as$B/health(health_router.py16, mounted atapp_fastapi.py819) is200(the credential is checked and accepted, becauseprima_webis granted). - Delete that user's
prima_webgrant and repeat thepubliccall:403"This account is not permitted on this surface."(the audit B1 behaviour: a public router still checks a presented credential). With noAuthorizationheader at all the same path is200. curl -s -i -H "Authorization: Bearer not-a-token" $B/api/workspaces/is401"Could not validate credentials", not403(an undecodable token is the endpoint's decision, and the gate leaks nothing);curl -s -i -H "X-API-Key: not-a-key" $B/api/workspaces/is the same401.- Stop the database (or
REVOKE SELECT ON user_surface_grants FROM <app role>) and repeat step 7's first call:503"Access control is temporarily unavailable."; restore and it is200. - On a route that uses
resolve_surface(B7'sPOST /api/auth/webauthn/login/begin-discoverable,webauthn_router.py232 to 242, which takes an optional body):curl -s -i -X POST $B/api/auth/webauthn/login/begin-discoverable -H 'Origin: https://prima.li' -H 'X-Alchemy-Surface: prima_chat'is400"Requested surface does not match the request origin."; withX-Alchemy-Surface: primapvsand noOriginit is400"That surface is external; Labs does not issue sessions for it."; withX-Alchemy-Surface: nowhereit is400beginning"Unknown surface. Expected one of: labs, prima_web, prima_chat, prima_dev". The magic link uses_requested_surfaceand answers400"Unknown surface 'nowhere'"for the explicit case and202for a conflictingOrigin(the two-resolver deviation). INSERT INTO user_surface_grants (user_id, surface, granted_at) VALUES (<uid>, 'primapvs', now())fails onck_usg_surface;INSERT ... VALUES (<uid>, 'labs', now())twice is one row (the second is a primary-key violation at the SQL level,ON CONFLICT DO NOTHINGthroughgrant_surfaces).python -c "from core.surfaces import SURFACES; [print(s.id, s.rp_id, s.rp_name, s.origins) for s in SURFACES.values() if not s.external]"prints four lines with noAttributeError, the three Prima lines sharingprima.liandPrima, and eachoriginstuple starting with the surface's own origin.
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.