fix(#532): gate checkpoint attempts on entitlements, not enrollments - #535
Conversation
…nts (#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
|
Merged as What landed
Verified before merge: Outstanding — the migration is not on the cloud project yet. It was applied to the local database only, deliberately: it tightens a policy in production and that is not a change to make unattended. Until Also worth a follow-up run on a quiet local database: |
…ble rows (#543) (#554) * fix(security): gate the write side on entitlements, not on self-issuable rows (#543) Four write-side policies constrained who a row belonged to but never whether the writer had earned it, so a client could assert its own completion, grade, mastery or tenant: - lesson_completions INSERT was `auth.uid() = user_id` and nothing else — any authenticated user could record a completion for any lesson id, and /api/certificates/issue counts exactly those rows to mint the school's credential. Now gated through `lessons` on has_course_access. - exam_submissions INSERT had no access predicate, so a student with no entitlement (or one past their school's access_cutoff_at) could enqueue AI grading on the school's plan budget. - lesson_checkpoint_attempts is now server-write-only, the #538 pattern: every column but the identity ones is a grading output, so no WITH CHECK could express the invariant. `{passed: true, score: 100}` satisfied any required checkpoint without answering, and rows tagged evaluator_type 'ai' billed the whole tenant's monthly AI allowance. The attempt route already held an admin client and its own access gate (#535); the insert moves there. - practice_attempts had no tenant predicate at all, and two SECURITY DEFINER triggers fire on that fully caller-controlled row — writing another school's shared Elo anchors and creating a gamification profile there, which is the eligibility source league rollover scans. INSERT now requires an active tenant_users row for the named tenant plus access to the named course, the blanket FOR ALL is split so graded history is append-only, and score/count columns are range-checked. Also replaces the last `enrollments` join in the checkpoint feature: the lesson_checkpoints SELECT policy authorized by enrollment, which no refund path ever removes, so a refunded student kept reading every enabled checkpoint. And /api/certificates/issue now resolves course access at the top of its self-serve branch rather than trusting the completion count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SbrtmdXw6ZwH2UeTCuT2v * fix(security): drop the out-of-scope course_id predicate on practice_attempts (#543) `course_id` on the record-practice MCP tool is an optional, model-supplied label ("Related course, if any" — mcp-server/src/tools/practice.ts:810), not a value the server derives from anything it verified. Gating the INSERT on it turned a mislabelled drill into a hard 42501, with no MCP test harness to catch the regression. It also bought little. The cross-tenant attack this policy exists to stop — driving another school's shared Elo anchors and appearing in its league standings — is closed entirely by the tenant_users membership check; `course_id` only scopes `item_ratings` rows within the caller's own tenant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SbrtmdXw6ZwH2UeTCuT2v --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
The lesson-checkpoint attempt API route now gates on
entitlements(viaresolveCourseAccessState()) instead of on anenrollmentsrow, the two sibling admin-client media routes get the same check as defence-in-depth, and a migration stops anenrollmentsrow from being usable as an access grant at the database level.Closes #532
Why
app/api/lesson-checkpoints/[checkpointId]/attempt/route.tsreads throughcreateAdminClient(), so the RLS backstop shipped in #509 (migration20260724150000) cannot apply to it, and the only gate it carried was an activeenrollmentsrow.That row has not meant "has access" since migration
20260516150000:lib/payments/webhook-dispatch.ts), andtenants.access_cutoff_at(Non-payment produces no access enforcement — over-limit tenants keep full access indefinitely #494) is read exclusively insidehas_course_access().WITH CHECKwas(auth.uid() = user_id) AND (tenant_id = get_tenant_id())— no entitlement predicate — so any tenant member couldPOST /rest/v1/enrollmentsfor any course in their tenant and manufacture the proof this route wanted. The sameEXISTSalso backedlesson_checkpoint_attempts_students_insert_own_rows, so the RLS insert the route deferred to was equally satisfiable.Three populations therefore reached paid checkpoint content: 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 row. What they got was AI evaluation with per-question feedback and score, plus writes to
lesson_checkpoint_attempts,exercise_evaluations,exercise_completionsand the XP ledger — unpaid consumption of the school's AI budget rather than a raw content dump, but the same root cause as #509: admin client plus the wrong gate.Changes
resolveCourseAccessState()replaces theenrollmentslookup. Both refusals are 403; the payload distinguishes a tenant suspension (accessSuspended) from a missing entitlement (accessDenied) so the checkpoint UI can say which, in translated copy (en+es) instead of the server's raw English.api/exercises/media/analyzeandapi/exercises/media/signed-urlwere ownership + tenant checked only. Neither is a live hole (both are reachable only behind the gatedupload-urlroute), but one spends AI credits and the other mints a storage URL off the admin client, and an entitlement can be revoked — or the tenant cutoff can pass — between the upload and the call. Both now callhasCourseAccess().20260725160000— theenrollmentsINSERT policy and thelesson_checkpoint_attemptsINSERT policy both now requirehas_course_access(). Safe for every legitimate writer:enroll_user(),self_enroll_subscription_course(),grant_free_entitlement()andissue_certificate_if_eligible()are allSECURITY DEFINER(verified inpg_proc) so RLS does not apply to them, and admin/seed paths use the service-role client. No client-sideenrollmentsINSERT exists in the app. The policy is tightened rather than dropped, so any caller that genuinely holds access keeps working.docs/MONETIZATION.mdclaimed the enforcement layer covered "the certificate, AI tutor and exercise-submission APIs"; it now lists the API-route layer accurately and states that anenrollmentsrow is not an access grant. Same note added todocs/DATABASE_SCHEMA.mdand as aCOMMENT ON TABLE, so the next reader does not reuse it as one.no-explicit-anyerrors in the media analyze route were fixed rather than bypassed with--no-verify, since lint-staged lints whole files.How to QA
Requires a dev server (
npm run dev) and the seeded local database.Automated (the fastest check):
npx playwright test checkpoint-access --workers=1 --project=desktop-chromium— three tests, all green. To see them catch the bug,git stashthe route change and re-run: the two refusal tests fail with 500 instead of 403.Manual, as
student@e2etest.com/password123onlvh.me:3000(Default School, course 1001):update entitlements set status='revoked' where user_id='a1000000-0000-0000-0000-000000000001' and course_id=1001;Submit again — the checkpoint shows "You don't have access to this course" and the API returns 403. Confirm the
enrollmentsrow is still there and stillactive; that is the point.status='active'), then simulate the Non-payment produces no access enforcement — over-limit tenants keep full access indefinitely #494 cutoff:update tenants set access_cutoff_at = now() - interval '1 minute' where id='00000000-0000-0000-0000-000000000001';Submit again — the copy now reads "Your school's access is currently suspended". Reset
access_cutoff_attonullafterwards.authenticatedrole with no entitlement for the course,INSERT INTO enrollments (...)is rejected with "new row violates row-level security policy", while the same insert succeeds with the entitlement intact.npx playwright test entitlements-overlap enrollment-flows --workers=1 --project=desktop-chromium— 11 passed.Screenshots / GIF
None — this is an API/RLS change. The only user-visible difference is the error copy in the checkpoint card for students who no longer have access, which QA step 3/4 above exercises directly.
Checklist
npm run typecheckandnpm run test:unitpass — typecheck clean, 342 unit tests passed (29 files)npm run buildpassestenant_id(or the table genuinely has no such column) — no new queries; the changed reads keep their existing tenant checksproxy.tskeeps staff out of/dashboard/student/*, andhas_course_access()has no staff branch by design); teacher-side checkpoint code reads attempts and never posts themcheckpoint-exercise-renderer.tsxmessages/en.jsonandmessages/es.jsonnpm run db:reset, has RLS policies, andlib/database.types.tswas regeneratedOn the last box: the migration was applied to the local database and both resulting policies re-read from
pg_policiesto confirm they landed as written, but a fullnpm run db:resetwas not run — another session was working against the same local database at the time and a reset would have destroyed its state. The migration is DDL-only (DROP POLICY/CREATE POLICY/COMMENT), adds no columns or tables, solib/database.types.tsis unchanged by design. Worth adb:reseton a quiet database before merge.The migration has not been applied to the cloud project — that is a deliberate hold, since it tightens a policy in production. Say the word and I'll apply it.