Skip to content

Commit a99befc

Browse files
jon-bellclaude
andcommitted
Rework student dashboard data path as a SECURITY DEFINER RPC
Replaces `public.assignments_for_student_dashboard` (security_invoker view) with `public.get_assignments_for_student_dashboard(p_class_id, p_student_profile_id)`, a SECURITY DEFINER plpgsql function. Per CLAUDE.md ("Prefer Postgres RPCs for data operations") and Jon's explicit guidance: - Authorization is one explicit gate at the top of the function: the caller must be either the requested student themselves OR an instructor/grader of the requested class. Unauthorized calls raise 42501. - `public.user_privileges` RLS is untouched and stays strictly the inlinable `(user_id = auth.uid())` only. The recursion trap from earlier drafts (function-based or inline-EXISTS policies on user_privileges) is avoided entirely because the RPC bypasses RLS by definer privilege. - The CTE chain is bounded by the (p_class_id, p_student_profile_id) args, so every downstream join is O(assignments) regardless of class size. - Defensive cleanup at the top of the migration drops any leftover policies/ helpers that earlier exploratory iterations may have applied locally (DROP ... IF EXISTS — no-op on fresh environments). Frontend callers updated: - app/course/[course_id]/assignments/page.tsx: replaces `useList({ resource: "assignments_for_student_dashboard", ... })` with `supabase.rpc("get_assignments_for_student_dashboard", { p_class_id, p_student_profile_id })`. - hooks/useGradebookWhatIf.tsx: same switch. This also fixes a latent view-as bug here (the prior code filtered by `courseController.userId`, which is always the real signed-in user, so the gradebook what-if wouldn't have matched a viewed-as student's row either). Types regenerated via `npm run client-local`; the view entry is gone from SupabaseTypes.d.ts and the new RPC appears under Functions. All 8 view-as E2E tests (4 specs × chromium + webkit) pass locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bfe13e9 commit a99befc

5 files changed

Lines changed: 23705 additions & 24041 deletions

File tree

app/course/[course_id]/assignments/page.tsx

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import { SelfReviewDueDate } from "@/components/ui/assignment-due-date";
66
import Link from "@/components/ui/link";
77
import { PageContainer } from "@/components/ui/page-container";
88
import { ResponsiveTable } from "@/components/ui/responsive-table";
9-
import useAuthState from "@/hooks/useAuthState";
109
import { useClassProfiles } from "@/hooks/useClassProfiles";
1110
import { useIdentity } from "@/hooks/useIdentities";
11+
import { createClient } from "@/utils/supabase/client";
1212
import { AssignmentGroup, AssignmentGroupMember, Repo } from "@/utils/supabase/DatabaseTypes";
1313
import { Database } from "@/utils/supabase/SupabaseTypes";
1414
import { InputGroup } from "@/components/ui/input-group";
1515
import { Box, EmptyState, Heading, Icon, Input, Skeleton, Stack, Table, Text } from "@chakra-ui/react";
16-
import { useState } from "react";
16+
import { useEffect, useState } from "react";
1717
import { BsSearch } from "react-icons/bs";
1818
import { TZDate } from "@date-fns/tz";
1919
import { useList } from "@refinedev/core";
@@ -43,12 +43,8 @@ type AssignmentUnit = {
4343
group: string;
4444
};
4545

46-
export type AssignmentsForStudentDashboard = Omit<
47-
Database["public"]["Views"]["assignments_for_student_dashboard"]["Row"],
48-
"id"
49-
> & {
50-
id: number;
51-
};
46+
export type AssignmentsForStudentDashboard =
47+
Database["public"]["Functions"]["get_assignments_for_student_dashboard"]["Returns"][number];
5248

5349
function formatLatestSubmissionLabel(assignment: AssignmentsForStudentDashboard): string {
5450
if (!assignment.submission_id) {
@@ -70,16 +66,11 @@ function formatLatestSubmissionLabel(assignment: AssignmentsForStudentDashboard)
7066
export default function StudentPage() {
7167
const { identities } = useIdentity();
7268
const { course_id } = useParams();
73-
const { user } = useAuthState();
7469
const { role } = useClassProfiles();
7570
const course = role.classes;
7671
const [query, setQuery] = useState("");
7772

7873
const private_profile_id = role.private_profile_id;
79-
// Use the effective role's user id (not `user?.id`) so the dashboard filter still matches
80-
// when an instructor is viewing as a student — `useAuthState` is always the real signed-in
81-
// user, while `role` from `useClassProfiles` follows view-as.
82-
const effective_user_id = role.user_id;
8374
const { data: groupsData } = useList<AssignmentGroupMemberWithGroupAndRepo>({
8475
resource: "assignment_groups_members",
8576
meta: {
@@ -95,23 +86,40 @@ export default function StudentPage() {
9586
});
9687
const groups: AssignmentGroupMemberWithGroupAndRepo[] | null = groupsData?.data ?? null;
9788

98-
const { data: assignmentsData, isLoading } = useList<AssignmentsForStudentDashboard>({
99-
resource: "assignments_for_student_dashboard",
100-
filters: [
101-
{ field: "class_id", operator: "eq", value: course_id },
102-
{ field: "student_user_id", operator: "eq", value: effective_user_id },
103-
{ field: "student_profile_id", operator: "eq", value: private_profile_id }
104-
],
105-
pagination: {
106-
pageSize: 1000
107-
},
108-
queryOptions: {
109-
enabled: !!user && !!private_profile_id
110-
},
111-
sorters: [{ field: "due_date", order: "desc" }]
112-
});
113-
114-
const assignments = assignmentsData?.data ?? null;
89+
// Dashboard data via SECURITY DEFINER RPC. The function checks authorization at the
90+
// top (caller is either the student themselves or an instructor/grader of the class)
91+
// and returns the student's assignment dashboard rows. Replaces the prior
92+
// `useList({ resource: "assignments_for_student_dashboard" })` on a view whose
93+
// security_invoker scoping couldn't accommodate the instructor read-only view-as path.
94+
const [assignments, setAssignments] = useState<AssignmentsForStudentDashboard[] | null>(null);
95+
const [isLoading, setIsLoading] = useState(true);
96+
useEffect(() => {
97+
if (!course_id || !private_profile_id) {
98+
return;
99+
}
100+
const supabase = createClient();
101+
let cancelled = false;
102+
setIsLoading(true);
103+
supabase
104+
.rpc("get_assignments_for_student_dashboard", {
105+
p_class_id: Number(course_id),
106+
p_student_profile_id: private_profile_id
107+
})
108+
.then(({ data, error }) => {
109+
if (cancelled) return;
110+
if (error) {
111+
// eslint-disable-next-line no-console
112+
console.error("Failed to load assignments dashboard:", error);
113+
setAssignments(null);
114+
} else {
115+
setAssignments(data ?? []);
116+
}
117+
setIsLoading(false);
118+
});
119+
return () => {
120+
cancelled = true;
121+
};
122+
}, [course_id, private_profile_id]);
115123

116124
const githubIdentity: UserIdentity | null = identities?.find((identity) => identity.provider === "github") ?? null;
117125

hooks/useGradebookWhatIf.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ export type GradebookColumnStudentWithMaxScore = Omit<GradebookColumnStudent, "s
4949
column_slug: string;
5050
};
5151

52-
type AssignmentForStudentDashboard = Database["public"]["Views"]["assignments_for_student_dashboard"]["Row"];
52+
type AssignmentForStudentDashboard =
53+
Database["public"]["Functions"]["get_assignments_for_student_dashboard"]["Returns"][number];
5354
class GradebookWhatIfController {
5455
private _grades: GradebookWhatIfGradeMap = {};
5556
public debugID: string = crypto.randomUUID();
@@ -72,14 +73,17 @@ class GradebookWhatIfController {
7273
}
7374

7475
private initializeGradebookGrades() {
75-
//Fetch all assignments for the student with their submissions
76+
// Fetch all assignments for the student with their submissions via the SECURITY
77+
// DEFINER RPC. The function authorizes at the top (caller is either the student
78+
// themselves or an instructor/grader of the class), so this works for the
79+
// instructor's read-only view-as path as well — using `this.courseController.userId`
80+
// here would otherwise be the real signed-in user, not the effective student.
7681
const client = createClient();
7782
client
78-
.from("assignments_for_student_dashboard")
79-
.select("*")
80-
.eq("class_id", this.gradebookController.class_id)
81-
.eq("student_user_id", this.courseController.userId)
82-
.eq("student_profile_id", this.private_profile_id)
83+
.rpc("get_assignments_for_student_dashboard", {
84+
p_class_id: this.gradebookController.class_id,
85+
p_student_profile_id: this.private_profile_id
86+
})
8387
.then(({ data }) => {
8488
this._assignments = data ?? [];
8589
});

0 commit comments

Comments
 (0)