Bind the request principal to the database session so policies can read it.
Why a one-shot bind cannot work
Both auth paths query the same session get_db yielded, autobeginning the transaction before a principal exists:
get_current_user_data_from_api_key runs db.query(AccessKey) at authentication.py:131.
get_current_user runs db.query(User) at authentication.py:187.
Setting the GUCs in the auth dependency is also insufficient. SET LOCAL is transaction-scoped, and MaveDB commits mid-request on every write path — 34 db.commit() calls in routers/. score_sets.py:2374 commits, then db.refresh(item), then model_validate(item), which lazy-loads relationships. All of that runs in a fresh transaction with no principal set.
The failure is not a quiet degrade to public. After COMMIT, current_setting('app.user_id', true) returns '' — not NULL, because SET LOCAL reverts to the session-level value and a custom GUC that has been set once carries '' forever on that pooled connection. ''::integer raises invalid input syntax for type integer: "", and RESET does not restore the unset state. Verified on PG 15.17. Without both halves below, create_score_set and publish_score_set return 500.
Both halves are required:
- The principal is stored on the
Session (session.info) by the auth dependency, once.
- A
SessionEvents.after_begin listener re-emits set_config for every transaction on that session, including ones opened after a mid-request commit.
bind_principal additionally applies retroactively when auth has already opened the transaction, which is what makes it safe for authentication to keep running on the request session, and retires the JWT/API-key transaction asymmetry as a correctness concern.
Scope
Session scope is told, never inferred
class SessionScope(enum.Enum):
REQUEST = "request" # RLS predicates apply
SYSTEM = "system" # worker, scripts, migrations
Set at construction — deps.get_db for the former, worker and scripts for the latter. The before_cursor_execute guard reads that, not starlette_context and not the HTTP method. Ambient inference fails open, so it is not an option here.
SessionScope.SYSTEM declared on the mavedb_api credential still sees only public rows. The declaration confers nothing — privilege comes from the credential, and the api's has none. There is no escape hatch in the api process to misuse.
The GUC set
app.user_id integer -- '' for anonymous
app.active_roles text[] -- '{}' for anonymous
active_roles rather than app.is_admin: a boolean drops UserRole.mapper, which reads private score sets, experiments, and experiment sets.
Emit with set_config('app.user_id', :uid, true), never an interpolated SET LOCAL — active_roles derives from the client-supplied X-Active-Roles header.
Policies read GUCs with missing_ok and nullif
nullif(current_setting('app.user_id', true), '')::integer. missing_ok alone leaves the '' failure above. With nullif, a connection carrying a stale '' degrades to public-only. Enforced by catalog test C9 in #824.
Auth tables
users stays carved out. access_keys does not — it carries a real policy, with the single pre-principal lookup served by app_resolve_access_key() (see #827).
Acceptance criteria
Bind the request principal to the database session so policies can read it.
Why a one-shot bind cannot work
Both auth paths query the same session
get_dbyielded, autobeginning the transaction before a principal exists:get_current_user_data_from_api_keyrunsdb.query(AccessKey)atauthentication.py:131.get_current_userrunsdb.query(User)atauthentication.py:187.Setting the GUCs in the auth dependency is also insufficient.
SET LOCALis transaction-scoped, and MaveDB commits mid-request on every write path — 34db.commit()calls inrouters/.score_sets.py:2374commits, thendb.refresh(item), thenmodel_validate(item), which lazy-loads relationships. All of that runs in a fresh transaction with no principal set.The failure is not a quiet degrade to public. After
COMMIT,current_setting('app.user_id', true)returns''— not NULL, becauseSET LOCALreverts to the session-level value and a custom GUC that has been set once carries''forever on that pooled connection.''::integerraisesinvalid input syntax for type integer: "", andRESETdoes not restore the unset state. Verified on PG 15.17. Without both halves below,create_score_setandpublish_score_setreturn 500.Both halves are required:
Session(session.info) by the auth dependency, once.SessionEvents.after_beginlistener re-emitsset_configfor every transaction on that session, including ones opened after a mid-request commit.bind_principaladditionally applies retroactively when auth has already opened the transaction, which is what makes it safe for authentication to keep running on the request session, and retires the JWT/API-key transaction asymmetry as a correctness concern.Scope
Session scope is told, never inferred
Set at construction —
deps.get_dbfor the former, worker and scripts for the latter. Thebefore_cursor_executeguard reads that, notstarlette_contextand not the HTTP method. Ambient inference fails open, so it is not an option here.SessionScope.SYSTEMdeclared on themavedb_apicredential still sees only public rows. The declaration confers nothing — privilege comes from the credential, and the api's has none. There is no escape hatch in the api process to misuse.The GUC set
active_rolesrather thanapp.is_admin: a boolean dropsUserRole.mapper, which reads private score sets, experiments, and experiment sets.Emit with
set_config('app.user_id', :uid, true), never an interpolatedSET LOCAL—active_rolesderives from the client-suppliedX-Active-Rolesheader.Policies read GUCs with
missing_okandnullifnullif(current_setting('app.user_id', true), '')::integer.missing_okalone leaves the''failure above. Withnullif, a connection carrying a stale''degrades to public-only. Enforced by catalog test C9 in #824.Auth tables
usersstays carved out.access_keysdoes not — it carries a real policy, with the single pre-principal lookup served byapp_resolve_access_key()(see #827).Acceptance criteria
after_beginfor every transaction.db.commit(), then reads it again in the same request.SETis verified to leak; this must useset_config(..., true).session.info, not request state.SessionScope.SYSTEMsession opened on the api credential sees public rows only.UserRole.mapperreads a private score set.conftest_optional.py:284'sget_current_useroverride moves down to the token/key resolution seam, so router tests exercisebind_principalrather than bypassing it. Without this, every test in the canary and differential matrix validates the override. See Test and local fixture parity with the production role topology #834.