|
| 1 | +-- Issue #542 (EPIC #540 §1.2) — gate the exam child tables and lesson comments |
| 2 | +-- through their parent, and stop the #509 preview branch overshooting. |
| 3 | +-- |
| 4 | +-- `20260313152048_rls_exam_child_tables.sql` created the SELECT policies on |
| 5 | +-- `exam_questions` and `question_options` as `USING (true)`. #509 replaced the |
| 6 | +-- **parent** `exams` policy with a tenant + staff/author + `has_course_access` |
| 7 | +-- gate and stopped there, so the answer key (`correct_answer`, `grading_rubric`, |
| 8 | +-- `ai_grading_criteria`, `expected_keywords`, `question_options.is_correct`) was |
| 9 | +-- readable over PostgREST by any authenticated user of any tenant, holding no |
| 10 | +-- entitlement, past their school's `access_cutoff_at`. `lesson_comments` is the |
| 11 | +-- same shape with lower stakes. |
| 12 | +-- |
| 13 | +-- These tables have NO `tenant_id` of their own (see CLAUDE.md — adding one to a |
| 14 | +-- query errors it outright, which is what broke teacher grading in #282), so the |
| 15 | +-- predicate routes through the FK to the parent that does. |
| 16 | +-- |
| 17 | +-- ALSO tightened, beyond the four SELECT policies the issue lists: the sibling |
| 18 | +-- `FOR ALL` staff policies on both exam tables. They read |
| 19 | +-- `get_tenant_role() IN ('teacher','admin')` with **no row predicate at all**, and |
| 20 | +-- permissive policies OR together — so without this a teacher of tenant A keeps |
| 21 | +-- reading (and writing) every exam question in every other tenant through that |
| 22 | +-- policy, and the SELECT fix below buys nothing for staff accounts. |
| 23 | +-- |
| 24 | +-- Every policy is DROP + CREATE, never supplemented: permissive policies OR |
| 25 | +-- together, so an added policy could only ever widen access. |
| 26 | + |
| 27 | +-- Shared predicate for the two exam child tables. Mirrors the #509 `exams` |
| 28 | +-- SELECT policy ("Students can view tenant exams") plus the pre-existing |
| 29 | +-- "Super admins can view all exams" policy, so a row is readable exactly when |
| 30 | +-- its parent `exams` row is. |
| 31 | +-- |
| 32 | +-- SECURITY INVOKER on purpose: `get_tenant_id()`, `auth.uid()` and |
| 33 | +-- `is_tenant_staff()` must resolve to the caller, and RLS on `exams` still |
| 34 | +-- applies inside it, which makes the check belt-and-braces rather than a |
| 35 | +-- second, drift-prone copy of the parent gate. |
| 36 | +CREATE OR REPLACE FUNCTION public.can_read_exam(_exam_id integer) |
| 37 | +RETURNS boolean |
| 38 | +LANGUAGE sql |
| 39 | +STABLE |
| 40 | +SECURITY INVOKER |
| 41 | +SET search_path TO 'public' |
| 42 | +AS $function$ |
| 43 | + SELECT EXISTS ( |
| 44 | + SELECT 1 FROM exams e |
| 45 | + WHERE e.exam_id = _exam_id |
| 46 | + AND ( |
| 47 | + (SELECT is_super_admin()) |
| 48 | + OR ( |
| 49 | + e.tenant_id = (SELECT get_tenant_id()) |
| 50 | + AND ( |
| 51 | + -- Staff branch. Teachers and admins hold no entitlement for the |
| 52 | + -- courses they author, and the exam editor, the teacher grading |
| 53 | + -- screens and the AI grading path all read these tables through an |
| 54 | + -- RLS-bound user client — drop this and grading breaks rather than |
| 55 | + -- fails safe. |
| 56 | + (SELECT is_tenant_staff()) |
| 57 | + OR EXISTS ( |
| 58 | + SELECT 1 FROM courses c |
| 59 | + WHERE c.course_id = e.course_id |
| 60 | + AND c.author_id = (SELECT auth.uid()) |
| 61 | + ) |
| 62 | + OR has_course_access((SELECT auth.uid()), e.course_id) |
| 63 | + ) |
| 64 | + ) |
| 65 | + ) |
| 66 | + ); |
| 67 | +$function$; |
| 68 | + |
| 69 | +COMMENT ON FUNCTION public.can_read_exam(integer) IS |
| 70 | + 'True when the caller may read the given exam under the #509 gate (tenant + staff/author/entitlement, or super admin). Used by the exam_questions and question_options SELECT policies, which have no tenant_id of their own (#542).'; |
| 71 | + |
| 72 | +-- Functions in `public` carry a default PUBLIC execute grant, which would put |
| 73 | +-- this on the REST surface for `anon` too. Neither exam child table is readable |
| 74 | +-- by anon, so anon never needs it. |
| 75 | +REVOKE EXECUTE ON FUNCTION public.can_read_exam(integer) FROM PUBLIC, anon; |
| 76 | +GRANT EXECUTE ON FUNCTION public.can_read_exam(integer) TO authenticated; |
| 77 | + |
| 78 | +-- exam_questions --------------------------------------------------------- |
| 79 | +DROP POLICY IF EXISTS "Authenticated users can view exam questions" ON public.exam_questions; |
| 80 | +CREATE POLICY "Users can view questions of readable exams" |
| 81 | + ON public.exam_questions FOR SELECT TO authenticated |
| 82 | + USING (public.can_read_exam(exam_questions.exam_id)); |
| 83 | + |
| 84 | +-- Staff CRUD, scoped to the tenant the caller is acting in. `get_tenant_role()` |
| 85 | +-- is kept (rather than swapped for `is_tenant_staff()`) to leave the write gate |
| 86 | +-- itself untouched; the change here is purely the added row predicate. |
| 87 | +DROP POLICY IF EXISTS "Teachers and admins can manage exam questions" ON public.exam_questions; |
| 88 | +CREATE POLICY "Teachers and admins can manage exam questions" |
| 89 | + ON public.exam_questions FOR ALL TO authenticated |
| 90 | + USING ( |
| 91 | + (SELECT public.get_tenant_role()) IN ('teacher', 'admin') |
| 92 | + AND EXISTS ( |
| 93 | + SELECT 1 FROM public.exams e |
| 94 | + WHERE e.exam_id = exam_questions.exam_id |
| 95 | + AND e.tenant_id = (SELECT public.get_tenant_id()) |
| 96 | + ) |
| 97 | + ) |
| 98 | + WITH CHECK ( |
| 99 | + (SELECT public.get_tenant_role()) IN ('teacher', 'admin') |
| 100 | + AND EXISTS ( |
| 101 | + SELECT 1 FROM public.exams e |
| 102 | + WHERE e.exam_id = exam_questions.exam_id |
| 103 | + AND e.tenant_id = (SELECT public.get_tenant_id()) |
| 104 | + ) |
| 105 | + ); |
| 106 | + |
| 107 | +-- question_options ------------------------------------------------------- |
| 108 | +-- Two hops: question_options -> exam_questions -> exams. |
| 109 | +DROP POLICY IF EXISTS "Authenticated users can view question options" ON public.question_options; |
| 110 | +CREATE POLICY "Users can view options of readable exams" |
| 111 | + ON public.question_options FOR SELECT TO authenticated |
| 112 | + USING ( |
| 113 | + EXISTS ( |
| 114 | + SELECT 1 FROM public.exam_questions q |
| 115 | + WHERE q.question_id = question_options.question_id |
| 116 | + AND public.can_read_exam(q.exam_id) |
| 117 | + ) |
| 118 | + ); |
| 119 | + |
| 120 | +DROP POLICY IF EXISTS "Teachers and admins can manage question options" ON public.question_options; |
| 121 | +CREATE POLICY "Teachers and admins can manage question options" |
| 122 | + ON public.question_options FOR ALL TO authenticated |
| 123 | + USING ( |
| 124 | + (SELECT public.get_tenant_role()) IN ('teacher', 'admin') |
| 125 | + AND EXISTS ( |
| 126 | + SELECT 1 FROM public.exam_questions q |
| 127 | + JOIN public.exams e ON e.exam_id = q.exam_id |
| 128 | + WHERE q.question_id = question_options.question_id |
| 129 | + AND e.tenant_id = (SELECT public.get_tenant_id()) |
| 130 | + ) |
| 131 | + ) |
| 132 | + WITH CHECK ( |
| 133 | + (SELECT public.get_tenant_role()) IN ('teacher', 'admin') |
| 134 | + AND EXISTS ( |
| 135 | + SELECT 1 FROM public.exam_questions q |
| 136 | + JOIN public.exams e ON e.exam_id = q.exam_id |
| 137 | + WHERE q.question_id = question_options.question_id |
| 138 | + AND e.tenant_id = (SELECT public.get_tenant_id()) |
| 139 | + ) |
| 140 | + ); |
| 141 | + |
| 142 | +-- lessons ---------------------------------------------------------------- |
| 143 | +-- #509 gave authenticated users a preview branch of |
| 144 | +-- `(is_preview AND status = 'published')`, justified as "a logged-in |
| 145 | +-- prospective buyer must not see less than an anonymous one". The anon policy |
| 146 | +-- it mirrors carries one more condition: the parent course must be published |
| 147 | +-- too. So a teacher drafting a course who marks a lesson preview + published |
| 148 | +-- exposed that lesson body to every signed-in member of the tenant while |
| 149 | +-- anonymous visitors correctly could not see it. Converge the two. |
| 150 | +-- |
| 151 | +-- Only the preview branch changes; the staff, author and entitlement branches |
| 152 | +-- are carried over verbatim from 20260724150000. |
| 153 | +DROP POLICY IF EXISTS "Users can view tenant lessons" ON public.lessons; |
| 154 | +CREATE POLICY "Users can view tenant lessons" |
| 155 | + ON public.lessons FOR SELECT TO authenticated |
| 156 | + USING ( |
| 157 | + tenant_id = (SELECT public.get_tenant_id()) |
| 158 | + AND ( |
| 159 | + (SELECT public.is_tenant_staff()) |
| 160 | + OR ( |
| 161 | + is_preview = true |
| 162 | + AND status = 'published' |
| 163 | + AND EXISTS ( |
| 164 | + SELECT 1 FROM public.courses c |
| 165 | + WHERE c.course_id = lessons.course_id |
| 166 | + AND c.status = 'published' |
| 167 | + AND c.tenant_id = lessons.tenant_id |
| 168 | + ) |
| 169 | + ) |
| 170 | + OR EXISTS ( |
| 171 | + SELECT 1 FROM public.courses c |
| 172 | + WHERE c.course_id = lessons.course_id |
| 173 | + AND c.author_id = (SELECT auth.uid()) |
| 174 | + ) |
| 175 | + OR public.has_course_access((SELECT auth.uid()), lessons.course_id::integer) |
| 176 | + ) |
| 177 | + ); |
| 178 | + |
| 179 | +-- lesson_comments -------------------------------------------------------- |
| 180 | +-- No tenant_id here either; mirror the lessons gate above through the FK, |
| 181 | +-- plus the pre-existing "Super admins can view all lessons" policy, so a |
| 182 | +-- thread is readable exactly when the lesson it hangs off is. |
| 183 | +DROP POLICY IF EXISTS "Authenticated users can view lesson comments" ON public.lesson_comments; |
| 184 | +CREATE POLICY "Users can view comments on readable lessons" |
| 185 | + ON public.lesson_comments FOR SELECT TO authenticated |
| 186 | + USING ( |
| 187 | + EXISTS ( |
| 188 | + SELECT 1 FROM public.lessons l |
| 189 | + WHERE l.id = lesson_comments.lesson_id |
| 190 | + AND ( |
| 191 | + (SELECT public.is_super_admin()) |
| 192 | + OR ( |
| 193 | + l.tenant_id = (SELECT public.get_tenant_id()) |
| 194 | + AND ( |
| 195 | + (SELECT public.is_tenant_staff()) |
| 196 | + OR ( |
| 197 | + l.is_preview = true |
| 198 | + AND l.status = 'published' |
| 199 | + AND EXISTS ( |
| 200 | + SELECT 1 FROM public.courses c |
| 201 | + WHERE c.course_id = l.course_id |
| 202 | + AND c.status = 'published' |
| 203 | + AND c.tenant_id = l.tenant_id |
| 204 | + ) |
| 205 | + ) |
| 206 | + OR EXISTS ( |
| 207 | + SELECT 1 FROM public.courses c |
| 208 | + WHERE c.course_id = l.course_id |
| 209 | + AND c.author_id = (SELECT auth.uid()) |
| 210 | + ) |
| 211 | + OR public.has_course_access((SELECT auth.uid()), l.course_id::integer) |
| 212 | + ) |
| 213 | + ) |
| 214 | + ) |
| 215 | + ) |
| 216 | + ); |
| 217 | + |
| 218 | +-- Supporting indexes. Each new predicate looks the child rows up by their FK, |
| 219 | +-- and none of these three FKs was indexed. |
| 220 | +CREATE INDEX IF NOT EXISTS idx_exam_questions_exam_id |
| 221 | + ON public.exam_questions (exam_id); |
| 222 | +CREATE INDEX IF NOT EXISTS idx_question_options_question_id |
| 223 | + ON public.question_options (question_id); |
| 224 | +CREATE INDEX IF NOT EXISTS idx_lesson_comments_lesson_id |
| 225 | + ON public.lesson_comments (lesson_id); |
0 commit comments