You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(security): gate checkpoint attempts on entitlements, not enrollments (#532)
The lesson-checkpoint attempt route reads through the service-role client, so
the RLS backstop added in #509 never applied to it, and the only gate it had
was an active `enrollments` row. That row has not meant "has access" since
migration 20260516150000: nothing revokes it (refunds revoke entitlements,
and tenants.access_cutoff_at is read only inside has_course_access), and its
RLS INSERT policy let any tenant member issue one for any course in their
tenant. Three populations could therefore spend the school's AI grading
budget and write progress records for content they had no right to: students
past their tenant's access cutoff (#494), students whose entitlement was
revoked by a refund (#498/#515), and anyone who self-inserted an enrollment.
- Route now calls resolveCourseAccessState() and returns a 403 that tells a
tenant suspension apart from a missing entitlement, so the checkpoint UI can
explain which one it is (new translated copy, en + es).
- Same check added to the two sibling admin-client media routes (analyze,
signed-url), which were ownership-checked only. Not live holes — both sit
behind the gated upload path — but both spend AI credits or mint storage
URLs, and an entitlement can be revoked after the upload was authorized.
- Migration 20260725160000 stops enrollments being an access grant at the
database level: its INSERT policy and the checkpoint-attempt INSERT policy
now both require has_course_access(). Every legitimate writer (enroll_user,
self_enroll_subscription_course, grant_free_entitlement,
issue_certificate_if_eligible) is SECURITY DEFINER, so none are affected.
- docs/MONETIZATION.md now describes the API-route layer accurately instead of
claiming page gates cover it, and both docs state that an enrollment row is
not an access grant.
New E2E spec pins the three states (entitled 200, revoked 403 accessDenied,
cutoff 403 accessSuspended); its two refusal cases fail against the old code.
The four pre-existing `no-explicit-any` errors in the media analyze route are
fixed rather than bypassed, since lint-staged lints the whole file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QSWA8GwmjsYHUS7ov5NG3
Copy file name to clipboardExpand all lines: docs/DATABASE_SCHEMA.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -456,6 +456,8 @@ Written by `enroll_user()` and `handle_new_subscription()`. Read via `has_course
456
456
457
457
> The old `product_id` / `subscription_id` columns and their "one or the other" CHECK constraint were **dropped** in the entitlements migration. If you find code or docs referencing them, they are out of date. Students on a subscription self-enroll from `/dashboard/student/browse` (`useEnrollment()` hook); subscriptions grant access but do not auto-enroll.
458
458
459
+
> **Never gate on the presence of an enrollment row** (issue #532). Nothing revokes one — refunds revoke *entitlements* and `tenants.access_cutoff_at` is read only inside `has_course_access()` — so a row survives every revocation path, and until migration `20260725160000` any tenant member could insert one for any course in their tenant. The INSERT policy now also requires `has_course_access()`; legitimate writers (`enroll_user()`, `self_enroll_subscription_course()`, `grant_free_entitlement()`, `issue_certificate_if_eligible()`) are all `SECURITY DEFINER` and unaffected.
Copy file name to clipboardExpand all lines: docs/MONETIZATION.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -243,7 +243,9 @@ The two sections above are **creation-time soft caps only** — they block a *ne
243
243
- Enforcement itself is in the database: once `access_cutoff_at` passes while the tenant is still over its plan's limits, `has_course_access()` (SQL function, migration `supabase/migrations/20260724130000_access_cutoff_enforcement.sql`) starts returning `false` for every student in that tenant. Access is restored automatically as soon as usage is back under the limit or the tenant upgrades.
244
244
- That function is enforced in two layers (issue #509 — before it, the cutoff was a no-op on exactly the pages that serve content):
245
245
-**RLS** (`supabase/migrations/20260724150000_content_entitlement_rls.sql`): the `authenticated` SELECT policies on `lessons`, `exercises`, `exams` and `lesson_resources` all require `has_course_access()`, with escape hatches for the course's author and for active teachers/admins of the tenant (`is_tenant_staff()`). Published preview lessons (`is_preview`) stay readable to everyone. This is the backstop — it holds even where a page uses the service-role client.
246
-
-**Page/route gates**: `requireCourseAccess()` (`lib/services/course-access-guard.ts`) on every student course route — course detail, lessons, exercises (list + detail), exams (list, taker, result, review), community — plus `hasCourseAccess()` in the certificate, AI tutor and exercise-submission APIs. A refusal caused by the tenant cutoff (rather than by a missing entitlement) sends the student to `/dashboard/student/access-suspended`, which explains that the school is over its limits and that their enrollment is intact.
246
+
-**Page gates**: `requireCourseAccess()` (`lib/services/course-access-guard.ts`) on every student course route — course detail, lessons, exercises (list + detail), exams (list, taker, result, review), community. A refusal caused by the tenant cutoff (rather than by a missing entitlement) sends the student to `/dashboard/student/access-suspended`, which explains that the school is over its limits and that their enrollment is intact.
247
+
-**API-route gates**: an API route that reads through `createAdminClient()` is outside the RLS backstop by construction, so it has to carry the check itself. The routes that serve or produce paid content all call `hasCourseAccess()` / `resolveCourseAccessState()` (`lib/services/course-access.ts` — the API-safe module; `course-access-guard.ts` imports `next/navigation` and is for pages only): `api/certificates/generate`, `api/chat/aristotle`, `api/exercises/artifact/evaluate`, `api/exercises/media/upload-url`, `api/exercises/media/analyze`, `api/exercises/media/signed-url`, and `api/lesson-checkpoints/[checkpointId]/attempt`. The checkpoint route returns a 403 that distinguishes a suspension from a missing entitlement, so the checkpoint UI can say which one it is.
248
+
-**An `enrollments` row is not an access grant** (issue #532). Since migration `20260516150000` it is a learning-progress record: nothing revokes it (refunds revoke *entitlements*, and `access_cutoff_at` is read only inside `has_course_access()`), and its RLS INSERT policy now requires `has_course_access()` precisely because it used to be self-issuable by any tenant member. Gate on entitlements — never on an enrollment row.
247
249
- This is a deliberate decision, not an oversight: with no production users yet, real enforcement was shipped directly rather than grandfathering pre-existing over-limit usage.
248
250
249
251
#### Blast radius — the cutoff is tenant-wide and all-or-nothing (issue #517)
Copy file name to clipboardExpand all lines: messages/es.json
+2Lines changed: 2 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -3735,6 +3735,8 @@
3735
3735
"retry": "Intentar de nuevo",
3736
3736
"review": "Editar y reenviar",
3737
3737
"genericError": "Algo salió mal. Inténtalo de nuevo.",
3738
+
"accessDenied": "No tienes acceso a este curso.",
3739
+
"accessSuspended": "El acceso de tu escuela está suspendido en este momento. Tu progreso está a salvo: pide a la administración de tu escuela que lo restablezca.",
3738
3740
"aiUnavailable": "Tu respuesta fue registrada, pero la retroalimentación con IA no está disponible en este momento.",
3739
3741
"externalPrompt": "Este checkpoint se completa en su página de ejercicio. Abre el ejercicio, termínalo allí y luego sincroniza tu resultado aquí.",
0 commit comments