`member:manage` + `project:manage_access` — and the second admin check `projectMembersService` re-implements privately
Two keys, one service, and one thing that turns out not to be a re-pointing at all.
lib/services/projectMembersService.ts does not call projectAccessService.assertCanManage. It declares its own module-private assertCanManage (top of the file) that re-derives the admin answer from scratch: workspace-manager rail, then projectMembership?.role === 'admin'. Four write methods call it — addMember, setRole, removeMember, setAccessLevel. So the members surface has been running on a second implementation of the access policy, which happens to agree with the first today and has no mechanism keeping it that way. The model story moved the policy into one place precisely so this cannot happen; this card is where that pays off.
addMember·setRole·removeMember→member:managesetAccessLevel→project:manage_access. It is its own key on purpose: who is IN the project and how open the project is to the workspace are different decisions, and Jira separates them the same way.
The read half is not a re-pointing. listMembers and getAccess are documented as deliberately un-gated reads ("available to any workspace member who can resolve the project key"). Confirm what resolveProjectInTx actually enforces before touching them: if it applies the browse gate, leave them alone; if it does not, a members list is readable by a workspace member who cannot browse the project, and project:browse is the right gate. Either way, do not put a manage key on a read — the Members page renders read-only for non-admins by design, and that is MOTIR-2258's surface to change, not this card's.
Acceptance criteria
- The module-private
assertCanManageinprojectMembersServiceis DELETED, and its four callers useprojectAccessService.assertPermission(project.id, ctx, <key>, tx)— threading the enclosingwithWorkspaceContexttransaction, which the existing calls already do and which the RLS binding requires. addMember,setRoleandremoveMembergate onmember:manage;setAccessLevelgates onproject:manage_access.- The two reads keep their current posture unless
resolveProjectInTxis found not to apply a browse gate, in which case they gate onproject:browse. Whichever is true, the finding is written into the service's header comment where today's "no gate" note sits. member:manageandproject:manage_accessflip toenforcement: 'enforced'inlib/permissions/catalog.ts.- The three rows in the inventory's
membersection have their gate-today cell corrected to name the real gate and their decision moved fromnewtoexisting. - Tests, per method: a project admin passes; a project member is refused 403; a workspace owner still passes on every access level (the always-pass rail); a non-browser gets 404, not 403. The 403 body still carries whatever code string
git grep NOT_PROJECT_ADMINshowed consumers reading. - The guard's pending pin is re-derived by running the suite on the branch, never copied from a sibling card.
pnpm testgreen;pnpm lintand the prettier check pass repo-wide.
Context refs
lib/services/projectMembersService.ts— the privateassertCanManage,listMembers,getAccess,addMember,setRole,removeMember,setAccessLevel, andresolveProjectInTx.app/api/projects/[key]/members/route.ts·app/api/projects/[key]/members/[userId]/route.ts·app/api/projects/[key]/access/route.ts— the three routes.lib/services/projectAccessService.ts—assertPermissionand itstxparameter.docs/decisions/permission-inventory.md— themembersection (reasons R27, R18).- The seam this calls · the model story.