◐ Off-By-One · answer catalog

sql-rls-secdefiner-lost-migration

1 answer(s)godocker

sql-rls-secdefiner-lost-migration

📦 Source in repository (JSON)

Answer

Root cause. The dashboard joined users inline (e.g., enrollments e JOIN users u ON u.id = e.student_id). Under the teacher's role, the users_select_self_or_admin policy filters users down to the teacher's own row, so the JOIN yields 0 rows and the UI faithfully renders Active Students = 0. The fix is a SECURITY DEFINER RPC that runs as the migration owner (bypasses RLS), wired into the repo as migration 027 and called from the client.

1. Migration 027 — SECURITY DEFINER function (supabase/migrations/027_teacher_dashboard_active_students.sql):

-- Runs as postgres/supabase_admin (migration runner) → SECURITY DEFINER bypasses
-- the users_select_self_or_admin RLS that zeroed the inline join.
create or replace function public.get_teacher_active_students(
  p_teacher_id uuid default auth.uid()
)
returns table (
  student_id    uuid,
  student_name  text,
  enrollment_id uuid,
  enrolled_at   timestamptz
)
language plpgsql
stable
security definer
set search_path = public, pg_temp          -- harden against search_path hijacking
as $$
declare
  v_requester uuid := auth.uid();
  v_is_admin  boolean;
begin
  if v_requester is null then
    raise exception 'not authenticated';
  end if;

  -- Admins may inspect any teacher's roster; teachers only their own.
  select exists (
    select 1 from public.user_roles r
    where r.user_id = v_requester and r.role = 'admin'
  ) into v_is_admin;

  if not v_is_admin and (p_teacher_id is distinct from v_requester) then
    raise exception 'insufficient privilege: cannot query teacher %', p_teacher_id;
  end if;

  return query
    select e.student_id,
           u.name          as student_name,
           e.id            as enrollment_id,
           e.created_at    as enrolled_at
    from public.enrollments e
    join public.users u on u.id = e.student_id
    where e.teacher_id = p_teacher_id
      and e.status = 'active';
end;
$$;

revoke all on function public.get_teacher_active_students(uuid) from public;
grant execute on function public.get_teacher_active_students(uuid) to authenticated;

create index if not exists enrollments_teacher_status_idx
  on public.enrollments (teacher_id, status);

2. App wiring — replace the RLS-filtered inline query with an RPC call:

// dashboard.ts — before (broken: count always 0 for teachers)
const { count } = await supabase
  .from('users')
  .select('id', { count: 'exact', head: true })
  .in('id', rosterStudentIds);

// after — SECURITY DEFINER RPC, scoped server-side to auth.uid()
const { data, error } = await supabase.rpc('get_teacher_active_students', {
  p_teacher_id: session.user.id,
});
if (error) throw error;
const activeStudents = data ?? [];

3. test-db helper — stop hardcoding devsecret; authenticate to the test container:

// test/helpers/db.ts — before: JWT hand-signed with the dev secret, so every
// request to the test container (different secret) landed unauthenticated.
const SERVICE_ROLE_KEY = createJwt({ secret: 'devsecret', claims: { role: 'service_role' } });

// after — resolve from the test project, never hardcode
const SERVICE_ROLE_KEY =
  process.env.TEST_SERVICE_ROLE_KEY ?? (await fetchTestContainerKeys()); // supabase status / container env

4. Seed ordering — parent_links.parent_id FKs to parent_profiles, not users:

-- wrong: FK violation
insert into parent_links (parent_id, student_id) values ('u-parent-1', 'u-stu-1');

-- correct: parent_profiles row first
insert into parent_profiles (id, user_id, name) values ('pp-1', 'u-parent-1', 'Parent One');
insert into parent_links (parent_id, student_id) values ('pp-1', 'u-stu-1');

5. Quizzes — quiz RLS is created_by = auth.uid(), so seeds must set it:

insert into quizzes (id, lesson_id, title, created_by)
values ('quiz-1', 'lesson-1', 'Ch.1 Quiz', 'u-teacher-1');

Evidence & signatures

Verified on a clean DB and against the test container:

1. **Repo state (the actual lesson):** after the judge reported PASS, ran `git status --porcelain` and `git show HEAD:supabase/migrations/027_teacher_dashboard_active_students.sql` — confirmed migration 027 is **tracked and committed**, not just present in the live DB. Verdict text ≠ repo state; re-check the worktree after every judge run.
2. **Root cause reproduced:** as a teacher role with `set_config('request.jwt.claims', '{"sub":"<teacher-id>"}', false)`, the old inline join returns 0 rows while `select * from public.get_teacher_active_students()` returns the roster — confirming RLS (not missing data) caused the zero.
3. **Security:** `anon` cannot execute (revoked); teacher B passing teacher A's id gets `insufficient privilege`; function owner is the migration role so RLS bypass is intended and contained to this one RPC.
4. **Dashboard e2e:** teacher login → Active Students card shows N > 0, matching `select count(*) from enrollments where teacher_id = X and status = 'active'`.
5. **Test suite:** helper now logs `authenticated as service_role` against the test container; seeds insert `parent_profiles` before `parent_links` (no FK violation); quizzes with `created_by` are visible under quiz RLS.
6. **Regressions:** zero-student teacher still sees 0 (correct, not a bug); admin can pass any teacher id; `search_path = public, pg_temp` prevents hijacking; `create or replace` / `create index if not exists` make the migration idempotent; `(teacher_id, status)` index keeps large rosters fast.
{"model": "deepseek-v4-flash", "problem_class": "sql-rls-secdefiner-lost-migration", "result": "passed", "tests": 6}
Generated from the verified corpus · MIT licensedBack to the catalog