Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-2470Done

The resolution arm — a membership on a custom role resolves THAT role's set, with both rails and the access-level subtraction untouched

The one change that makes a custom role mean anything. lib/permissions/resolve.ts is the whole project access policy expressed once as a function from three resolved facts to a permission set; this card gives it a fourth fact and one new arm, and changes nothing else about it.

The change

ProjectPermissionInputs gains a nullable custom role permission set. In resolvePermissions the only line that moves is the one that picks the BASE:

const base = customRolePermissions ?? (projectRole is a ProjectRole
  ? BUILTIN_ROLE_PERMISSIONS[projectRole]
  : IMPLICIT_WORKSPACE_MEMBER_PERMISSIONS);

Everything above and below it stays exactly as it is, and each of those is load-bearing:

  • The level-gated grants stay layer 1. A public project's public_request:* keys are decided by the access level for every actor; no role, custom or not, can hold or withhold them.
  • The workspace-manager rail stays layer 2 and stays ABOVE the custom set. An owner or admin holds the whole role-gated catalog on every access level and returns early — a custom role can never narrow them, which is what keeps a project's own admins from being locked out by a role somebody authored.
  • The null-deny rail stays layer 3. Someone outside the workspace holds nothing beyond layer 1, whatever a project role says.
  • levelGrants is not touched at all. It names only work_item:edit, comment:add and attachment:create; every other key takes the default arm. Because a membership on a custom role carries role = CUSTOM_ROLE_TIER (member), the limited and private subtractions take NOTHING away from a custom role — it grants exactly what it lists, on every access level (Yue, 2026-08-09). That is deliberate: the tier subtraction narrows the COARSE built-ins, and a set an admin enumerated by hand is not coarse. Adding a branch here for a custom role would re-introduce exactly that second-guessing; do not.

The IO half is resolveInputs in lib/services/projectAccessService.ts: it already reads the project, the workspace membership and the project membership. It gains the role definition for a membership that has one — through the existing tx when a caller supplies one, so the snapshot and the RLS workspace GUC are shared. getPermissions, getPermissionsDTO, assertPermission, assertCanManage and every assertCan* predicate in lib/projects/access.ts inherit the new behaviour with no signature change, because they all go through this one resolve.

Scope boundary

In: the resolvePermissions arm, the resolveInputs read, and the tests that prove both. Out: any change to levelGrants, to either rail, or to BUILTIN_ROLE_PERMISSIONS / IMPLICIT_WORKSPACE_MEMBER_PERMISSIONS; the five getXCapabilities methods, which the permission-gated UI story re-points when it delivers the set to the client; anything that WRITES a role definition or a membership pointer.

Acceptance criteria

  • resolvePermissions returns a membership's custom role set as the base when one is present, and the built-in / implicit set when it is not — with the level-gated layer, both rails and levelGrants byte-identical to what shipped.
  • A workspace owner or admin resolves the full role-gated catalog even when their project membership points at a custom role that grants almost nothing — the rail wins, asserted directly.
  • An actor with no workspace membership resolves nothing beyond the level-gated layer even with a custom role attached — the null-deny rail wins, asserted directly.
  • tests/permissions/accessParity.test.ts still passes UNCHANGED: with no custom roles in play the resolved answer is identical for all 64 actors. A card that has to edit that table has changed behaviour it was not asked to change.
  • A new truth table extends the parity proof across the access levels for a membership on a custom role, showing that every level leaves its set intact — and its mirror asserts a BUILT-IN role is still narrowed exactly as before, so the change cannot leak past custom roles.
  • A permission in a stored role's array that is not in ROLE_GATED_PERMISSIONS — a key retired from the catalog after the role was authored — is ignored by the resolution rather than granted, and a test pins that: the catalog is the source of truth over a stored array, and stale data may never widen access.
  • resolveInputs reads the role definition in the same round trip and the same tx as the membership, so a caller inside a transaction shares the snapshot and the workspace GUC; a test drives assertPermission inside a transaction and shows no extra connection is used.
  • lib/permissions/resolve.ts stays pure — no Prisma import, no IO — and a test imports it in a bare context to prove it.
  • The integration tests in tests/permissions/getPermissions.integration.test.ts gain the custom-role cases against real Postgres, including one under a foreign workspace GUC showing the definition is invisible and the actor resolves as if it did not exist.

Context refs

  • lib/permissions/resolve.tsProjectPermissionInputs, resolvePermissions, its three layers, and levelGrants with its "deliberately unchanged" note.
  • lib/services/projectAccessService.tsresolveInputs, getPermissions, getPermissionsDTO, assertPermission.
  • lib/permissions/builtinRoles.tsBUILTIN_ROLE_PERMISSIONS, IMPLICIT_WORKSPACE_MEMBER_PERMISSIONS, ROLE_GATED_PERMISSIONS.
  • lib/projects/access.ts — the eleven predicates that inherit this with no change.
  • tests/permissions/accessParity.test.ts — the 64-row table that must stay green; tests/permissions/getPermissions.integration.test.ts — the integration suite this extends.
  • The schema card — the table this reads and the paired role / roleDefinitionId invariant this depends on.
  • The card that introduced getPermissions and the one that expressed roles as sets.