Implementation — the role claim comes from app_metadata only
Security fix in app/core/auth.py. Companion records:
docs/30_decisions/adr-0001-auth-admin-jwt-role.md (2026-09-26 amendment) and
docs/50_data_model/access-control.md.
Finding
get_current_user resolved the role with a fallback to a user-writable claim:
user_metadata is self-service: any logged-in user can PUT /user against GoTrue, which is
publicly exposed (supabase.bacmr.com proxies to the stack). Nothing exploitable today only because
the Postgres access-token hook always sets app_metadata.role to at least student, so the truthy
left operand keeps the or from ever falling through. That is a property of a deployment detail,
not of the code: disable the hook, let it error, or drop its SELECT grant on public.profiles and
every caller picks their own role — including admin, which require_admin then accepts for
ingestion, scraping sync and user management.
Change
No fallback. A missing claim degrades to student (least privilege), never to a client-supplied
value. This is exactly what the legacy gateway's JwtVerifier already did
(app_metadata.role ?? 'student'), so both services now agree.
Tests (tests/core/test_auth.py)
test_role_comes_from_app_metadata— the hook-set claim is honoured.test_user_metadata_role_is_ignored_when_app_metadata_has_no_role(admin,teacher) — the exact unmasked case: noapp_metadatarole at all, and the answer is stillstudent.test_user_metadata_role_cannot_override_app_metadata_role.test_require_admin_rejects_a_self_set_user_metadata_role— end of the chain: 403, not admin.test_require_admin_still_accepts_an_app_metadata_admin— control, so the 403 above is not vacuous.
Confirmed non-vacuous the other way too: restoring the or user.user_metadata.get(...) fallback
fails 3 of them.
Verification
pytest(project venv,ENVIRONMENT=testperpytest.ini): 794 passed (devbaseline: 788).ruff check app tests— no new findings (3 pre-existing ones in unrelated test modules remain);ruff format --checkclean for both touched files except a pre-existing line intests/core/test_auth.pythat was left alone to keep this diff focused.