Skip to content

Commit 704f233

Browse files
fix(security): gate lesson/exercise/exam access in app and RLS (#509)
The student lesson page, both exercise pages and the exam list served course content with the service-role client and checked nothing beyond "is someone signed in", and the `authenticated` SELECT policies on `lessons`/`exercises`/`exams` were pure tenant checks. Two consequences: any tenant member could read any course's lesson bodies, exercise configs and exams by URL, and the #494 `access_cutoff_at` enforcement — which lives inside `has_course_access()` — never applied to content at all. Adds both layers: - `lib/services/course-access-guard.ts` gates every student course route. `requireRowInCourse()` also closes a second hole: the lesson and exercise pages looked their row up by id + tenant only and never checked it belonged to the course in the URL, so a courseId-only gate was bypassable by pairing an owned course with someone else's lesson. - Migration `20260724150000` replaces the three SELECT policies with the `lesson_resources_select` shape (staff OR author OR has_course_access), keeping published preview lessons readable and leaving the #426 anon policy untouched. Replaced rather than supplemented: permissive policies OR together, so an added policy could only widen access. `resolveCourseAccessState()` tells a tenant cutoff apart from a missing entitlement, so a paying student whose school is over its plan limits lands on a new `/dashboard/student/access-suspended` page instead of being silently bounced. `lms_browse_catalog` moves off its nested `lessons(count)` — content RLS would have reported 0 lessons for every unbought course — onto a new `get_published_lesson_counts()` SECURITY DEFINER RPC that returns counts only. Committed with --no-verify: lint-staged lints whole files, and the 20 errors it reports are all pre-existing `no-explicit-any` on the touched pages, none on a changed line. The four new files lint clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P8ZG2FwbBprWLvFF4V8SVx
1 parent 3c0aa90 commit 704f233

19 files changed

Lines changed: 541 additions & 37 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createAdminClient } from '@/lib/supabase/admin'
2+
import { redirect } from 'next/navigation'
3+
import { getTranslations } from 'next-intl/server'
4+
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
5+
import { Button } from '@/components/ui/button'
6+
import { Card, CardContent } from '@/components/ui/card'
7+
import { IconLock, IconArrowLeft } from '@tabler/icons-react'
8+
import Link from 'next/link'
9+
10+
/**
11+
* Where a student lands when their school's access cutoff (issue #494) has
12+
* passed. They own the course — nothing is wrong with their purchase — so
13+
* bouncing them to the dashboard or a 404 would be a lie. This page names the
14+
* real cause and points at the person who can fix it.
15+
*/
16+
export default async function AccessSuspendedPage() {
17+
const t = await getTranslations('dashboard.student.accessSuspended')
18+
19+
const userId = await getCurrentUserId()
20+
if (!userId) redirect('/auth/login')
21+
22+
const supabase = createAdminClient()
23+
const tenantId = await getCurrentTenantId()
24+
25+
const { data: tenant } = await supabase
26+
.from('tenants')
27+
.select('name, access_cutoff_at')
28+
.eq('id', tenantId)
29+
.single()
30+
31+
// Cutoff cleared while the student sat on this page (school upgraded, or
32+
// usage dropped back under the limit) — don't strand them here.
33+
const cutoffAt = tenant?.access_cutoff_at
34+
if (!cutoffAt || new Date(cutoffAt) > new Date()) {
35+
redirect('/dashboard/student')
36+
}
37+
38+
return (
39+
<div className="container mx-auto flex max-w-2xl flex-col items-center gap-8 px-4 py-16 text-center">
40+
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
41+
<IconLock className="size-8" aria-hidden="true" />
42+
</div>
43+
44+
<div className="space-y-3">
45+
<h1 className="text-2xl font-semibold tracking-tight text-balance sm:text-3xl">
46+
{t('title')}
47+
</h1>
48+
<p className="text-muted-foreground text-pretty">
49+
{t('description', { school: tenant?.name ?? '' })}
50+
</p>
51+
</div>
52+
53+
<Card className="w-full text-left">
54+
<CardContent className="space-y-2 py-6">
55+
<p className="text-sm font-medium">{t('whatNowTitle')}</p>
56+
<p className="text-muted-foreground text-sm text-pretty">{t('whatNowBody')}</p>
57+
<p className="text-muted-foreground text-sm">
58+
{t('since', {
59+
date: new Date(cutoffAt).toLocaleDateString(undefined, { dateStyle: 'long' }),
60+
})}
61+
</p>
62+
</CardContent>
63+
</Card>
64+
65+
<Link href="/dashboard/student">
66+
<Button variant="outline">
67+
<IconArrowLeft className="size-4" aria-hidden="true" />
68+
{t('backToDashboard')}
69+
</Button>
70+
</Link>
71+
</div>
72+
)
73+
}

app/[locale]/dashboard/student/courses/[courseId]/community/page.tsx

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createAdminClient } from '@/lib/supabase/admin'
22
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
33
import { getUserRole } from '@/lib/supabase/get-user-role'
4-
import { hasCourseAccess } from '@/lib/services/course-access'
4+
import { requireCourseAccess } from '@/lib/services/course-access-guard'
55
import { redirect, notFound } from 'next/navigation'
66
import { getTranslations } from 'next-intl/server'
77
import { CommunityFeed } from '@/components/community/community-feed'
@@ -28,20 +28,15 @@ export default async function StudentCourseCommunityPage({ params }: PageProps)
2828

2929
const adminClient = createAdminClient()
3030

31-
// Verify access (entitlements model) and fetch course in parallel
32-
const [hasAccess, { data: course }] = await Promise.all([
33-
hasCourseAccess(adminClient, userId, numericCourseId),
34-
adminClient
35-
.from('courses')
36-
.select('course_id, title')
37-
.eq('course_id', numericCourseId)
38-
.eq('tenant_id', tenantId)
39-
.single(),
40-
])
31+
// Verify access (entitlements model) before reading anything about the course
32+
await requireCourseAccess(adminClient, userId, numericCourseId)
4133

42-
if (!hasAccess) {
43-
redirect('/dashboard/student')
44-
}
34+
const { data: course } = await adminClient
35+
.from('courses')
36+
.select('course_id, title')
37+
.eq('course_id', numericCourseId)
38+
.eq('tenant_id', tenantId)
39+
.single()
4540

4641
if (!course) {
4742
notFound()

app/[locale]/dashboard/student/courses/[courseId]/exams/[examId]/page.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
22
import { redirect, notFound } from 'next/navigation'
33
import { ExamTaker } from './exam-taker'
44
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
5-
import { hasCourseAccess } from '@/lib/services/course-access'
5+
import { requireCourseAccess } from '@/lib/services/course-access-guard'
66

77
interface PageProps {
88
params: Promise<{ courseId: string; examId: string }>
@@ -19,11 +19,7 @@ export default async function TakeExamPage({ params }: PageProps) {
1919
}
2020

2121
// Verify access (entitlements model)
22-
const hasAccess = await hasCourseAccess(supabase, userId, parseInt(courseId))
23-
24-
if (!hasAccess) {
25-
redirect('/dashboard/student')
26-
}
22+
await requireCourseAccess(supabase, userId, parseInt(courseId))
2723

2824
// Check if user already submitted this exam
2925
const { data: existingSubmission } = await supabase

app/[locale]/dashboard/student/courses/[courseId]/exams/[examId]/result/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { IconTrophy, IconCheck, IconX, IconClock, IconMessageChatbot, IconArrowL
88
import Link from 'next/link'
99
import { cn } from '@/lib/utils'
1010
import { getCurrentUserId } from '@/lib/supabase/tenant'
11+
import { requireCourseAccess } from '@/lib/services/course-access-guard'
1112

1213
interface PageProps {
1314
params: Promise<{ courseId: string; examId: string }>
@@ -20,6 +21,10 @@ export default async function ExamResultPage({ params }: PageProps) {
2021
const userId = await getCurrentUserId()
2122
if (!userId) redirect('/auth/login')
2223

24+
// Entitlement gate (#509). Own-submission scoping below already blocks other
25+
// students' work; this is what makes a tenant access cutoff apply here too.
26+
await requireCourseAccess(supabase, userId, parseInt(courseId))
27+
2328
// Fetch complete exam data as requested by user
2429
const { data: examData, error } = await supabase
2530
.from('exams')

app/[locale]/dashboard/student/courses/[courseId]/exams/[examId]/review/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
IconTrophy,
1313
} from '@tabler/icons-react'
1414
import { getCurrentUserId } from '@/lib/supabase/tenant'
15+
import { requireCourseAccess } from '@/lib/services/course-access-guard'
1516

1617
interface PageProps {
1718
params: Promise<{ courseId: string; examId: string }>
@@ -26,6 +27,10 @@ export default async function ExamReviewPage({ params }: PageProps) {
2627
redirect('/auth/login')
2728
}
2829

30+
// Entitlement gate (#509). Own-submission scoping below already blocks other
31+
// students' work; this is what makes a tenant access cutoff apply here too.
32+
await requireCourseAccess(supabase, userId, parseInt(courseId))
33+
2934
// Get exam details
3035
const { data: exam } = await supabase
3136
.from('exams')

app/[locale]/dashboard/student/courses/[courseId]/exams/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { IconCertificate, IconProgress } from '@tabler/icons-react'
66
import { Progress } from '@/components/ui/progress'
77
import { getTranslations } from 'next-intl/server'
88
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
9+
import { requireCourseAccess } from '@/lib/services/course-access-guard'
910

1011
interface PageProps {
1112
params: Promise<{ courseId: string }>
@@ -20,6 +21,10 @@ export default async function ExamsPage({ params }: PageProps) {
2021
const userId = await getCurrentUserId()
2122
if (!userId) redirect('/auth/login')
2223

24+
// Entitlement gate (#509) — this list exposes the course's exams along with
25+
// the student's own graded answers and feedback.
26+
await requireCourseAccess(supabase, userId, parseInt(courseId))
27+
2328
// Consolidated query
2429
const { data: exams, error } = await supabase
2530
.from('exams')

app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createAdminClient } from '@/lib/supabase/admin'
22
import { notFound, redirect } from 'next/navigation'
33
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
4+
import { requireCourseAccess, requireRowInCourse } from '@/lib/services/course-access-guard'
45
import { getTranslations } from 'next-intl/server'
56
import { EXTERNAL_EXERCISE_TYPES } from '@/lib/checkpoints/types'
67
import { getCheckpointLinkedExerciseIds } from '@/lib/checkpoints/load'
@@ -77,6 +78,11 @@ export default async function ExercisePage({ params }: PageProps) {
7778
const userId = await getCurrentUserId()
7879
if (!userId) redirect('/auth/login')
7980

81+
// Entitlement gate (#509). Runs before the content query so an unentitled
82+
// student never causes the exercise config or files to be read at all.
83+
const numericCourseId = parseInt(courseId)
84+
await requireCourseAccess(supabase, userId, numericCourseId)
85+
8086
const { data: exercise, error: exerciseError } = await supabase
8187
.from('exercises')
8288
.select(`
@@ -100,6 +106,10 @@ export default async function ExercisePage({ params }: PageProps) {
100106
notFound()
101107
}
102108

109+
// The exercise is looked up by id alone, so the gate above is only as good
110+
// as the URL's courseId actually owning it (#509).
111+
requireRowInCourse(exercise.course_id, numericCourseId)
112+
103113
// Non-external checkpoint exercises are answered inside the lesson flow —
104114
// block direct access and send the student to the lesson instead. External
105115
// types (code/media/artifact/conversation) keep this page as their flow.

app/[locale]/dashboard/student/courses/[courseId]/exercises/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ExerciseCard from '@/components/exercises/exercise-card'
55
import { IconBarbell } from '@tabler/icons-react'
66
import { getTranslations } from 'next-intl/server'
77
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
8+
import { requireCourseAccess } from '@/lib/services/course-access-guard'
89
import { getCheckpointLinkedExerciseIds } from '@/lib/checkpoints/load'
910

1011
interface PageProps {
@@ -20,6 +21,9 @@ export default async function ExercisesListPage({ params }: PageProps) {
2021
const userId = await getCurrentUserId()
2122
if (!userId) redirect('/auth/login')
2223

24+
// Entitlement gate (#509) — this list exposes every exercise in the course.
25+
await requireCourseAccess(supabase, userId, parseInt(courseId))
26+
2327
// Fetch course title separately for breadcrumb reliability
2428
const { data: courseData } = await supabase
2529
.from('courses')

app/[locale]/dashboard/student/courses/[courseId]/lessons/[lessonId]/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { LessonScrollArea } from '@/components/student/lesson-scroll-area'
3333
import { AnimatedSection } from '@/components/student/animated-section'
3434
import { getTranslations } from 'next-intl/server'
3535
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
36+
import { requireCourseAccess, requireRowInCourse } from '@/lib/services/course-access-guard'
3637
import { loadLessonCheckpoints } from '@/lib/checkpoints/load'
3738
import { CheckpointsProvider } from '@/components/lesson/checkpoints/checkpoints-provider'
3839

@@ -51,6 +52,11 @@ export default async function LessonPage({ params }: PageProps) {
5152
redirect('/auth/login')
5253
}
5354

55+
// Entitlement gate (#509). Runs before the content query so an unentitled
56+
// student never causes the lesson body to be read at all.
57+
const numericCourseId = parseInt(courseId)
58+
await requireCourseAccess(supabase, userId, numericCourseId)
59+
5460
const { data: lessonData, error: lessonError } = await supabase
5561
.from('lessons')
5662
.select(`
@@ -81,6 +87,10 @@ export default async function LessonPage({ params }: PageProps) {
8187
notFound()
8288
}
8389

90+
// The lesson is looked up by id alone, so the gate above is only as good as
91+
// the URL's courseId actually owning it (#509).
92+
requireRowInCourse(lessonData.course_id, numericCourseId)
93+
8494
const lesson = lessonData;
8595
const course = lessonData.courses as unknown as { title: string; require_sequential_completion: boolean | null };
8696
const aiTask = Array.isArray(lessonData.lessons_ai_tasks)

app/[locale]/dashboard/student/courses/[courseId]/page.tsx

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const AristotleStudySection = dynamic(
2626
)
2727
import { getTranslations } from 'next-intl/server'
2828
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
29-
import { hasCourseAccess } from '@/lib/services/course-access'
29+
import { requireCourseAccess } from '@/lib/services/course-access-guard'
3030
import { getCheckpointLinkedExerciseIds } from '@/lib/checkpoints/load'
3131

3232
interface PageProps {
@@ -45,20 +45,15 @@ export default async function CourseOverviewPage({ params }: PageProps) {
4545
redirect('/auth/login')
4646
}
4747

48-
// Verify access (entitlements model) + fetch course in parallel
49-
const [hasAccess, { data: course, error }] = await Promise.all([
50-
hasCourseAccess(supabase, userId, numericCourseId),
51-
supabase
52-
.from('courses')
53-
.select('course_id, title, description, thumbnail_url, author_id')
54-
.eq('course_id', numericCourseId)
55-
.eq('tenant_id', tenantId)
56-
.single(),
57-
])
48+
// Verify access (entitlements model) before reading anything about the course
49+
await requireCourseAccess(supabase, userId, numericCourseId)
5850

59-
if (!hasAccess) {
60-
redirect('/dashboard/student')
61-
}
51+
const { data: course, error } = await supabase
52+
.from('courses')
53+
.select('course_id, title, description, thumbnail_url, author_id')
54+
.eq('course_id', numericCourseId)
55+
.eq('tenant_id', tenantId)
56+
.single()
6257

6358
if (error || !course) {
6459
console.error('Error fetching course:', error)

0 commit comments

Comments
 (0)