Skip to content

Lesson-checkpoint attempt route gates on a self-issuable enrollment row, bypassing entitlements and the access cutoff (#509 follow-up) #532

Description

@guillermoscript

Follow-up to #493 / #494 / #509 — found reviewing the epic's shipped code (2026-07-25).

Problem

#509 (PR #518) gated every student page with requireCourseAccess() and made RLS the database backstop for lessons / exercises / exams (migration 20260724150000). The lesson-checkpoint attempt API route is outside both halves of that fix: it reads with the service-role client, so RLS never applies, and its access check is an enrollments row rather than has_course_access().

Evidence

app/api/lesson-checkpoints/[checkpointId]/attempt/route.ts:

:66   const adminClient = createAdminClient()          // → #509's RLS backstop cannot apply
:70   .from('lesson_checkpoints').select('… exercises(… system_prompt, exercise_config, course_id …)')
:77   if (!checkpoint || checkpoint.tenant_id !== tenantId )   // tenant check only

:98   const { data: enrollment } = await adminClient
        .from('enrollments')
        .select('enrollment_id')
        .eq('user_id', user.id)
        .eq('course_id', exercise.course_id)
        .eq('status', 'active')
:106  if (!enrollment) return Response.json({ error: 'Not enrolled in this course' }, { status: 403 })

No has_course_access / requireCourseAccess call anywhere in the file (grep -rn "hasCourseAccess\|has_course_access" app does not list it).

Why the enrollment gate is not an access check:

  1. enrollments has been a learning-progress record only since migration 20260516150000 — access lives in entitlements. Nothing revokes an enrollment: webhook-dispatch.ts:261-266 revokes entitlements on refund, and tenants.access_cutoff_at (Non-payment produces no access enforcement — over-limit tenants keep full access indefinitely #494) is read exclusively inside has_course_access().

  2. The row is self-issuable. Verified against the cloud project:

    enrollments / INSERT / {authenticated}
    with_check: (auth.uid() = user_id) AND (tenant_id = get_tenant_id())
    

    No entitlement predicate — any tenant member can POST /rest/v1/enrollments for any course_id in their tenant and satisfy this route's gate. (The same predicate also backs lesson_checkpoint_attempts_students_insert_own_rows, so the RLS insert policy this check mirrors is equally self-satisfiable.)

Impact

Three populations reach paid checkpoint content through this route despite the epic's enforcement:

What they get: AI evaluation of the checkpoint exercise, per-question feedback and score (Response.json(result) at :315), plus writes to lesson_checkpoint_attempts, exercise_evaluations, exercise_completions and the XP ledger. Narrower than the original #509 finding — system_prompt and exercise_config stay server-side, so this is unpaid consumption of paid AI and progress-writing rather than a raw content dump — but it is the same root cause: admin client plus the wrong gate.

Wanted

  1. Replace the enrollments lookup with the real access check — resolveCourseAccessState() / hasCourseAccess() on exercise.course_id — returning 403 for a missing entitlement and something distinguishable (or the same 403) for a tenant cutoff. lib/services/course-access.ts is the API-safe module; course-access-guard.ts imports next/navigation and is for pages only.
  2. Audit the sibling admin-client routes for the same pattern while in there — app/api/exercises/media/analyze/route.ts and app/api/exercises/media/signed-url/route.ts are tenant/ownership-checked only (both currently reachable only via the gated upload path, so they are defence-in-depth, not live holes).
  3. Consider tightening the enrollments INSERT policy, or explicitly documenting that an enrollment row is not an access grant so the next reader does not reuse it as one. Note lesson_checkpoint_attempts' insert policy leans on it too.

docs/MONETIZATION.md's enforcement section should end up accurate about API routes, not just pages — that claim being wrong for lessons/exercises is what #509 was.

Metadata

Metadata

Labels

Sub-taskbugSomething isn't workingsecuritySecurity vulnerability or hardeningseverity:highHigh severity security finding

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions