Custom project roles — author a role from a base, choose its permissions, assign members to it
Let a project admin create their own roles. A custom role is named by its author, scoped to one project, started from a built-in BASE, and defined by the permissions it holds over the catalog established in the catalog story and expanded per domain in the per-domain split. The read-only Roles & permissions page gains its write affordances; the Members admin's role picker gains the project's custom roles alongside the three built-ins.
The shape (rung 2 — verified on origin/main, 2026-08-08)
- Schema. A
ProjectRoleDefinitionrow per custom role —{ id, workspaceId, projectId, name, basedOn, permissions[] }— with a per-project unique name. It carries its ownworkspace_idcolumn and theFOR ALL USING ("workspace_id" = current_setting('app.workspace_id', true))policy every workspace-bearing table takes;prisma/migrations/20260608224711_add_project_membership_and_roles/migration.sqlis the pattern to copy verbatim, not an RLS join throughproject.ProjectMembershipgains a nullable pointer to a role definition; a null pointer means the membership still names a BUILT-IN role through its existingroleenum column, so no existing row is migrated and no member loses access on deploy. - Built-ins stay immutable and stay in code.
lib/permissions/builtinRoles.tskeeps owning them; they are not seeded as editable rows. Cloning one produces a custom role; the built-in itself can never be edited or deleted, which is what makes "reset to the default" always available and what the mirror does. - A role is its NAME and its SET. Nothing records where it started (Yue, 2026-08-09). The editor still offers a built-in to start from — an empty grid of twenty-eight checkboxes is a worse starting point than a role that already works, which is GitHub's base-role pattern and what the epic cites — but that pick SEEDS THE GRID AND IS NOT STORED. An earlier revision kept a
basedOncolumn feeding aBased on Viewer · +2chip; it was provenance, not inheritance — a snapshot that never re-flows, so a claim about how the role was once authored rather than a fact about what it is, and stale the moment either side was edited. A role that lists its permissions already says everything true about itself, and the list row'sN of 28gives the coarse comparison. - A membership on a custom role sits at ONE tier (
member), so the ACCESS LEVEL SUBTRACTS NOTHING from it — a custom role grants exactly what it lists, on every level. This is the one behaviour consequence of the line above, and it is deliberate: the level's tier subtraction exists to narrow the COARSE built-in roles, and a set an admin enumerated by hand is not coarse. A role that listedwork_item:editand silently did not have it would be the bug.privatestill requires a membership to see the project at all, and a BUILT-IN role is narrowed exactly as it always was. - The grid is grouped by DOMAIN, and it is the read screen's list with its marks swapped for checkboxes. The design asset measured and rejected inventing a second grammar for authoring, so the editor walks
permissionsByDomain({ include: ROLE_GATED_PERMISSIONS })in exactly the order the detail screen already walks. Whether a domain heading also carries a bulk grant-all / clear-all control — the affordance Jira's named bundles amount to on a catalog that is already grouped — is not drawn today, and the design amendment in this story decides and draws it rather than a code card improvising one. - Deleting a role reassigns its members. A role in use cannot vanish under the people holding it: deletion requires choosing the role its members move to, in the shape
workflowsService.deleteStatusalready shipped for workflow statuses — a typed in-use refusal carrying the affected count when no target is given, and a reassign-then-delete in one transaction when one is (lib/services/workflowsService.ts,StatusInUseError, and theReassignModalinWorkflowEditor.tsx). Custom-field options are the counter-example, not the precedent:customFieldsService.deleteOptionthrowsOptionInUseErrorand the UI offers archive instead. There is no per-project issue-type vocabulary at all. - Caps. A bounded number of custom roles per project, refused with a typed error rather than a silent truncation — the same posture as
lib/customFields/limits.ts'MAX_FIELDS_PER_PROJECT/FieldLimitReachedError. - A permission no gate consults can never be granted. The server refuses any key outside the role-gated,
enforcedset, read from the constants rather than a literal list.PLANNED_PERMISSIONSis empty onorigin/maintoday (MOTIR-2356 emptied it), so the editor's non-interactive treatment for a planned key is currently inert — the refusal is the standing invariant, and it must be written so the next planned key is refused without a code change.
Scope boundary
In: the schema, migration and repository; the resolution arm that reads a membership's custom permission set; the role-definition service and its project-admin-gated API; the Checkbox primitive the editor needs; the role editor page (create from base, rename, edit permissions); the list and detail screens' admin write affordances and the delete-with-reassign dialog; the Members admin role picker extended to custom roles; the design amendment for the surfaces the shipped asset does not draw; the story's two test subtasks.
Out: workspace-scoped custom roles — the workspace MemberRole is untouched, as the epic records, and the workspace-manager always-pass rail in lib/permissions/resolve.ts survives intact; sharing a role definition across projects (Jira's reusable permission SCHEME) — a project's roles are its own, and a scheme that spans projects is not planned; role assignment through groups; any NEW catalog key — the catalog is the model story's and this story only lets a role choose among keys that already exist; hiding settings entries the actor cannot use, which is the permission-gated UI story's; and the product-wide --el-text-faint sweep, which is its own bug.
Verification recipe
- Pull the story branch,
pnpm install,pnpm prisma migrate dev,pnpm dev. - As a project admin, open Project settings → Access → Roles & permissions → Create role. Start from Member, name it Contributor, and remove
work_item:triageandsprint:manage. Save. The role appears in the list with aCustomchip and itsN of M permissions. - Drill into Contributor: the detail screen shows its
Customchip, itsN of Mand its member count, and carriesEditandDelete. There is no "based on" chip — nothing records which built-in seeded it. - Open Members, change a teammate to Contributor, and sign in as them: they can create and edit work items and comment, and the sprint controls refuse.
- Back as admin, delete Contributor: the dialog names how many members hold it and requires choosing a replacement role, and the teammate holds that replacement afterwards — never no role at all.
- Confirm the built-ins cannot be damaged: Admin, Member and Viewer offer no edit or delete affordance, and a direct
PATCH/DELETEagainst one is refused. pnpm test— the permission-resolution matrix covers a membership on a custom role, a membership on a built-in, and the workspace-manager rail overriding both.