typescript-rbac-school-match-guard
Root cause: the route guard matched only on schoolId, and GET / early-returned [] for NULL-schoolId staff. Since users.school_id is NULLable for self-registered teachers while their class rows carry a real school_id, the school axis alone can never admit them — ownership is the missing access axis.
1. Fix the guard (src/guard.ts) — teacher-owner short-circuits before any school comparison. SQL NULL semantics are modeled faithfully: a NULL schoolId matches no school (NULL = NULL is unknown in SQL, so the JS port must not let null === null pass):
// BUG (before): schoolId-only match — 403'd fresh teacher on their own class
// return user.schoolId !== null && cls.schoolId === user.schoolId;
export function canAccessClass(user: User, cls: ClassRow): boolean {
if (cls.teacherId === user.id) return true; // owner axis (fresh-teacher bypass)
return user.schoolId !== null && cls.schoolId === user.schoolId; // school axis
}
2. Apply the guard to all four routes — GET /:id, PUT /:id, DELETE /:id, GET /:id/roster go through one wrapper so the bypass applies uniformly. Order matters: RLS-scoped read first (invisible row ⇒ 404, indistinguishable from missing), then the route guard (visible-but-denied ⇒ 403):
function withClass(repo, guard, user, id, fn): Promise<HttpResult> {
const cls = await repo.getClassById(user, id); // RLS-scoped: null => 404
if (!cls) return { status: 404, body: { error: "class not found" } };
if (!guard(user, cls)) return { status: 403, body: { error: "forbidden" } };
return fn(cls);
}
// each route:
get: (user, id) => withClass(repo, guard, user, id, (c) => ({ status: 200, body: c }))
update: (user, id, p) => withClass(repo, guard, user, id, async (c) => ({ status: 200, body: await repo.updateClass(user, id, p) }))
remove: (user, id) => withClass(repo, guard, user, id, async () => { await repo.deleteClass(user, id); return { status: 200, body: { deleted: id } }; })
roster: (user, id) => withClass(repo, guard, user, id, async () => ({ status: 200, body: await repo.getRoster(user, id) }))
3. Fix GET / — delete the NULL-schoolId empty early-return; list by teacherId (filtering already existed in repo.listClasses):
// BUG (before): if (user.schoolId === null) return { status: 200, body: [] };
const filter =
user.schoolId === null ? { teacherId: user.id } : { schoolId: user.schoolId };
const rows = await repo.listClasses(user, filter); // RLS re-applied per row
return { status: 200, body: rows };
Cross-tenant isolation is untouched: non-owners from other schools are filtered out at the RLS layer (404), and same-school staff/owners keep 200. The guard can only 403 rows RLS already exposed — after this fix, every RLS-visible row is admitted by owner-axis or school-axis.
Reproduced in a runnable harness (`tests/run.test.ts`) with Node v22 native TS type-stripping; `tsc --noEmit` clean. The legacy (pre-fix) guard is retained as `canAccessClassLegacy` only to prove the regression is real. **A. Bug reproduced against pre-fix guard** — fresh teacher (`school_id = NULL`) on own class `c-3`: - `GET /c-3`, `PUT /c-3`, `DELETE /c-3`, `GET /c-3/roster` → all `403` (guard denied owner) - `GET /` → `[]` (early-return) - NULL schoolId correctly matches no school (`canAccessClassLegacy` false) **B. Fix verified** (24/24 PASS) — same teacher after fix: - `GET /c-3` → `200` + class body; `PUT` → `200` + renamed; `GET /c-3/roster` → `200` + 2 students; `DELETE` → `200`, subsequent `GET` → `404` (row gone) - `GET /` → `200` with exactly `["c-3"]` (no early-return, no leak of other tenants' classes) **C. Isolation preserved:** - sch-b teacher on sch-a class (non-owner): `GET /c-1`, `GET /c-3`, `GET /c-3/roster` → all `404` (RLS hides); their list → only `c-2` - NULL-schoolId teacher on someone else's class (`c-1`) → `404` (ownership is the only axis that admits them) - Same-school non-owner teacher and same-school admin on `c-3` → `200` (school axis intact) Edge cases covered: NULL-semantics of school matching, owner with NULL schoolId, owner with real schoolId, same-school colleague, admin, cross-tenant 404 vs 403 layering, delete-then-read tombstone, list leak check.
{"model": "deepseek-v4-flash", "problem_class": "typescript-rbac-school-match-guard", "result": "passed", "tests": 24}