feat(rbac): drop the project-scoped Admin role - #377
Conversation
This PR contains a database migration
It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:
|
Admin is a user-level flag. `users.is_admin` is the only thing that grants it, and `buildSubject` never reads it from a membership -- so the `Admin` value in `project_memberships.role` only ever meant "director of this project", because `DIRECTOR_ROLES` held both spellings. It was a synonym for Director that the new staff picker would have offered as if it meant something more. `PROJECT_ROLES` is now `Director | Student` and `DIRECTOR_ROLES` is just `Director`. Everything downstream follows from that one list: the picker's options, `validateMembers`' allowlist, and the two OpenAPI enums. The migration is the contract phase 20260812011405 promised and never got. It rewrites the surviving `Admin` rows to `Director` -- which changes nobody's permissions, for the reason above -- and narrows the CHECK, which had still been accepting `PI`, `Accountant` and `Staff` a year after they were renamed. Deliberately not mapped to `users.is_admin`: that would turn a project-scoped role into global privilege, which is the bug being removed, not a migration. Verified against a scratch Postgres: Admin rows become Director and the constraint then rejects an Admin insert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c95e170 to
e46d789
Compare
|
Database Types Check Complete The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones. No changes were needed and the type definitions are already up to date. |
|
Database Types Check Complete The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones. No changes were needed and the type definitions are already up to date. |
These fixtures predate this branch -- they arrived with #372 and only met the narrowed vocabulary at the rebase. `rbac.test.ts` asserted that a project_id 4 membership makes the caller a director of it, which held only because DIRECTOR_ROLES used to include Admin; the case is about loadRbacSubject not issuing a second query, so Director expresses it without relying on the synonym. The authenticate.test.ts rows are pass-through assertions that were passing either way, updated so no fixture claims a role the CHECK now rejects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Database Types Check Complete The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones. No changes were needed and the type definitions are already up to date. |
Follows #374 (now merged); rebased onto
main.Answering the question first: do we branch off that membership anywhere?
Exactly one line.
buildSubjectinshared/rbac/src/subject.tschecks the membership role againstDIRECTOR_ROLES, which was['Admin', 'Director']. That is the only place in the repo where behaviour depends on a membership role string —DIRECTOR_ROLEShas one reference, and everything downstream reads the deriveddirectorProjectIdsinstead.isAdminis never derived from a membership; it comes only fromusers.is_admin. So a project-scopedAdminrow granted exactly what aDirectorrow granted, and nothing else. It was a synonym.Every other appearance of the role is a vocabulary list, a display pass-through (
GET /projects/{id}/overview, the reports PDF/CSV, the staff card), a spec enum, or docs.Change
PROJECT_ROLES→['Director', 'Student'],DIRECTOR_ROLES→['Director']. Because both the picker's options andvalidateMembers' allowlist re-export that one list, the UI stops offering Admin and the API starts rejecting it with no further edits.20260825023851_drop_project_admin_role.sql: rewrites survivingAdminrows toDirector, then narrows the CHECK to('Director','Student').shared/rbac/README.md, and the staleapps/backend/AGENTS.mdrole list (which still saidPI/Accountant/Staff/Admin) updated.Adminrows are mapped toDirector, not tousers.is_admin = true. Promoting them would convert a project-scoped role into global privilege — the exact confusion being removed.Notes on the migration
It is the contract phase that
20260812011405promised and never delivered — that migration widened the CHECK to six values (Admin, Director, Student, PI, Accountant, Staff) with a comment saying "a later migration drops them", and the live constraint has accepted all six ever since. This narrows it.Per
apps/backend/db/README.mdthis is a narrowing constraint, so the honest caveat: migrations run against prod before the lambda deploys. In that window the old lambda still acceptsrole: 'Admin'and would now hit a constraint violation. The deployed frontend has no role picker at all (#374 adds it), so nothing in the product can post that value — only a direct API call in a ~2 minute window could. No destructive-SQL opt-out is needed; the guard matchesDROP TABLE/COLUMN/TRUNCATE, notDROP CONSTRAINT.Verification
Adminrow: the row becameDirectorand a subsequentINSERT ... 'Admin'was rejected byproject_memberships_role_check. Scratch DB dropped.shared/rbac: 30/30 pass, including two new cases — the vocabulary has no Admin, and a staleAdminmembership row yields neitherisAdminnor director.apps/backend/lambdas/projects: newtest/member-roles.unit.test.ts, 2/2 pass — every surviving role is accepted,Adminis rejected with'role' must be one of: Director, Student.apps/frontend:tsc --noEmitclean and 38/38 suites, 367 tests pass after rebuilding the rbac dist, plus a new case asserting the dropdown offers onlyDirectorandStudent.npm run buildandnext lintinapps/frontend. Both were green earlier on this tree and nothing since touched frontend source. CI covers them.🤖 Generated with Claude Code