Skip to content

Commit 30ac6fe

Browse files
Merge pull request #327 from guillermoscript/fix/exercise-eval-media-rls
fix(rls): close cross-tenant leak in exercise evaluation/media RLS
2 parents ecdf0cf + fcc5bc4 commit 30ac6fe

2 files changed

Lines changed: 114 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
-- exercise_evaluations: unified AI-evaluation record across all exercise engines
2+
-- (text/simulation via lib/ai/tools.ts + artifact/evaluate route, audio/video via media/analyze route).
3+
-- Code wrote+read this table for months but the CREATE was never shipped, so every AI
4+
-- exercise-evaluation insert failed silently (relation-not-found) and the student exercise
5+
-- history + the artifact-route rate-limit both read nothing. This migration adds the table
6+
-- the live code already expects. Shape derived verbatim from the insert/select call sites.
7+
8+
create table if not exists public.exercise_evaluations (
9+
id bigint generated by default as identity primary key,
10+
exercise_id bigint not null references public.exercises(id) on delete cascade,
11+
user_id uuid not null references public.profiles(id) on delete cascade,
12+
tenant_id uuid not null references public.tenants(id) on delete cascade,
13+
engine_type text not null, -- 'text' | 'simulation' | 'audio' | 'video'
14+
submission_id bigint, -- polymorphic ref (e.g. exercise_media_submissions.id); no FK
15+
submission_source text, -- e.g. 'exercise_media_submissions'
16+
score numeric(5,2),
17+
passed boolean not null default false,
18+
ai_result jsonb not null default '{}'::jsonb,
19+
ai_metrics jsonb,
20+
attempt_number integer not null default 1, -- auto-assigned by trigger (no caller sets it)
21+
created_at timestamptz not null default now()
22+
);
23+
24+
-- Serves both hot reads: page history (exercise_id+user_id+tenant_id, order by created_at desc)
25+
-- and the artifact-route rate-limit count (same keys + created_at >= now()-1h).
26+
create index if not exists exercise_evaluations_lookup_idx
27+
on public.exercise_evaluations (exercise_id, user_id, tenant_id, created_at desc);
28+
29+
-- attempt_number is read by the student exercise page but written by no caller; auto-number it
30+
-- per (user, exercise) so the displayed history is meaningful.
31+
create or replace function public.set_exercise_evaluation_attempt_number()
32+
returns trigger
33+
language plpgsql
34+
as $$
35+
begin
36+
select count(*) + 1
37+
into new.attempt_number
38+
from public.exercise_evaluations
39+
where exercise_id = new.exercise_id
40+
and user_id = new.user_id;
41+
return new;
42+
end;
43+
$$;
44+
45+
create trigger trg_exercise_evaluation_attempt
46+
before insert on public.exercise_evaluations
47+
for each row execute function public.set_exercise_evaluation_attempt_number();
48+
49+
-- RLS: a student has full access to their own rows; a teacher/admin may read any row in a
50+
-- tenant where they hold an active role. Tenant scope is derived from authoritative
51+
-- `tenant_users` membership (bound to auth.uid()), NOT the `x-tenant-id` request header —
52+
-- the header is client-settable on a direct PostgREST call, so a header-only teacher policy
53+
-- would let any authenticated user read another tenant's evaluations by spoofing it.
54+
-- Server-side writes use the service-role admin client and bypass RLS.
55+
alter table public.exercise_evaluations enable row level security;
56+
57+
-- Students: own rows only. user_id is globally unique and bound to the JWT, so this fully
58+
-- scopes access without any tenant header.
59+
create policy students_own_evaluations on public.exercise_evaluations
60+
for all
61+
using ( user_id = (select auth.uid()) )
62+
with check ( user_id = (select auth.uid()) );
63+
64+
-- Teachers/admins: rows in tenants where they hold an active teacher/admin membership.
65+
create policy teachers_view_tenant_evaluations on public.exercise_evaluations
66+
for select
67+
using (
68+
exists (
69+
select 1
70+
from public.tenant_users tu
71+
where tu.user_id = (select auth.uid())
72+
and tu.tenant_id = exercise_evaluations.tenant_id
73+
and tu.role in ('teacher', 'admin')
74+
and tu.status = 'active'
75+
)
76+
);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
-- Fix cross-tenant leak in exercise_media_submissions RLS.
2+
--
3+
-- The original policies (migration 20260302200000_add_voice_exercises.sql) scoped tenant via
4+
-- the `x-tenant-id` request header:
5+
--
6+
-- teachers_view_tenant_media_submissions USING ( tenant_id = header('x-tenant-id') )
7+
--
8+
-- That header is client-settable on a direct PostgREST call, and the teacher policy had no
9+
-- user/role predicate. SELECT policies are OR'd, so ANY authenticated user could read every
10+
-- row of an arbitrary tenant by sending `x-tenant-id: <victim-tenant-uuid>`. The student
11+
-- policy was safe (bound by `user_id = auth.uid()`), but it also relied on the spoofable
12+
-- header for its tenant clause.
13+
--
14+
-- Fix: derive tenant scope from authoritative `tenant_users` membership (bound to auth.uid()),
15+
-- never the header. Server-side writes still use the service-role admin client (bypass RLS).
16+
17+
drop policy if exists "students_own_media_submissions" on public.exercise_media_submissions;
18+
drop policy if exists "teachers_view_tenant_media_submissions" on public.exercise_media_submissions;
19+
20+
-- Students: own rows only. user_id is globally unique and bound to the JWT.
21+
create policy "students_own_media_submissions" on public.exercise_media_submissions
22+
for all
23+
using ( user_id = (select auth.uid()) )
24+
with check ( user_id = (select auth.uid()) );
25+
26+
-- Teachers/admins: rows in tenants where they hold an active teacher/admin membership.
27+
create policy "teachers_view_tenant_media_submissions" on public.exercise_media_submissions
28+
for select
29+
using (
30+
exists (
31+
select 1
32+
from public.tenant_users tu
33+
where tu.user_id = (select auth.uid())
34+
and tu.tenant_id = exercise_media_submissions.tenant_id
35+
and tu.role in ('teacher', 'admin')
36+
and tu.status = 'active'
37+
)
38+
);

0 commit comments

Comments
 (0)