Skip to content

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:

role = user.app_metadata.get("role") or user.user_metadata.get("role", "student")

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

role = user.app_metadata.get("role", "student")

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: no app_metadata role at all, and the answer is still student.
  • 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=test per pytest.ini): 794 passed (dev baseline: 788).
  • ruff check app tests — no new findings (3 pre-existing ones in unrelated test modules remain); ruff format --check clean for both touched files except a pre-existing line in tests/core/test_auth.py that was left alone to keep this diff focused.