rbac is not a checkbox
permission bugs never announce themselves — they show up as a coach staring at another school's data. here's the sentence-first framework that fixed it.
every backend tutorial ends the same way: users table, roles table, join table, ship. i believed that until uniz and abc chess taught me the same lesson in the same year — rbac isn't a schema problem. it's a product problem wearing database clothes.
the failure mode is never rbac broken in your logs. it's a coach opening a dashboard and seeing another school's batch. a parent who can't view their kid's homework. an admin who can edit records they shouldn't know exist. no stack trace helps. you get a confused human on whatsapp and a trust problem that outlives the bug fix.
the sentence test
stop thinking in tables. start thinking in sentences.
who is this user? what resource are they touching? what action are they allowed to take?
write the answer as a single line before you write middleware:
coach + student record + read progress = yes. parent + another family's record + anything = no.
if you can't say it in one sentence, the rule isn't ready to implement. full stop.
the roles we actually shipped
uniz and abc chess looked different on paper. the permission shape was nearly identical.
- admin — full org access. can mutate users, batches, and settings within their school.
- coach — read/write within assigned batches only. nothing cross-batch, nothing cross-school.
- parent — read-only on linked student profiles. no mutations, no sibling data.
- student — own profile, own submissions. nothing else.
- substitute — time-boxed coach access. this one broke us twice.
three layers, one rule
security lives in different places. only one of them stops curl.
- ui — hides buttons the user shouldn't click. necessary for ux. useless against a direct request.
- api — rejects unauthorized calls before your handler runs. this is the line.
- db — row-level policies, when you use them. strong last resort. not a substitute for api checks.
ui hiding is politeness. api enforcement is security. build both, but ship api first.
function canReadStudent(user: User, student: Student): boolean {
if (user.role === 'admin') return user.orgId === student.orgId;
if (user.role === 'coach') return user.batchIds.includes(student.batchId);
if (user.role === 'parent') return user.linkedStudentIds.includes(student.id);
return false;
}prisma made the schema changes tractable — explicit relations between users, roles, and scoped resources. the hard part was never syntax. it was sitting with the admin and listing every "can x do y" rule until the edge cases stopped sounding edge-y.
the playbook i'd run again
- draw roles on paper before touching code
- list the weird cases — substitutes, archived users, cross-school admins, expired access
- implement api checks first. verify with
curl, not clicks - add ui hiding last
- write one integration test per role that hits the wrong endpoint on purpose
permissions feel abstract until someone sees the wrong kid's data. then they're the most concrete thing in your backlog. build for that moment — not the tutorial diagram.