Spec: F73 — Centralized Roles & Permissions (RBAC)
The Contract Graph owns POST /api/members/{id}/role/ through set_member_role; collected pytest
proof does not establish browser, native, live authorization, Redis, Sentry, or production evidence.
Status: shipped (web)
Extends F02 Multi-tenant orgs & roles from five fixed roles into a centralized, capability-based permission layer — sophisticated in the backend, simple in the frontend. This is the scope change that un-strikes “Advanced RBAC” from MVP_SCOPE.md → Out of Scope (decided 2026-06-15).
1. Problem & CEO Job
Trust the signal + decide fast. Lumi’s three hardcoded gates — IsOrgMember,
IsOrgOwnerOrAdmin, and IsOrgManager (backend/apps/organizations/permissions.py) — map to five
fixed roles (owner/admin/manager/member/viewer). That is too blunt: an owner may delegate F70 Chaos
Import without granting admin, a sales lead may edit but not delete
deals (F50 CRM 2.0), and finance may see billing but not the work graph. Without a
central model, every new capability needs a bespoke backend class and frontend if (role === …),
causing drift, over-permissioning, and 403 dead ends. The CEO needs one answer to “who can do what?”
to trust the system with company data.
2. User Stories & Acceptance Criteria
-
As an owner, I want to grant or revoke a single capability for a role from one screen, so that I control access without learning a permission system.
- Given a fresh or legacy org opening the Roles & Permissions screen for the first time, then the five system presets and a small set of seeded starter custom roles are already present (for example import/operator, project-coordinator, sales-lead, and access-admin starters), so the owner does not start from a blank matrix.
- Given the Roles & Permissions settings page, when the owner toggles
crm.deal.deleteoff for themanagerrole, then managers immediately receive403 permission_deniedon deal delete and the Delete control is hidden in their UI. - Given a capability change, when it is saved, then an F16 audit entry records actor, role, capability, before→after, and timestamp.
-
As an admin, I want to allow specific members to use a tool (e.g. Chaos Import input), so that I delegate without promoting them to admin.
- Given a custom role “Paster” with only
chaos.import.create, when a member is assigned it, then they can open/intel/importand submit, but cannotsavea brief (needschaos.import.save). - Given a member without
chaos.import.create, when they open/intel/import, then they see the no-permission state — never a silent 403 after typing.
- Given a custom role “Paster” with only
-
As a member, I want to only see actions I’m allowed to take, so that the UI is honest.
- Given any capability-gated control, when the member lacks the capability, then the control is hidden (not merely disabled) and the route, if entered directly, shows the no-permission state.
-
As an owner, I want the five built-in roles to keep working unchanged, so that nothing I rely on breaks.
- Given a fresh or existing org, when F73 ships, then owner/admin/manager/member/viewer resolve to the same effective access they had before (preset capability sets), with zero migration action.
-
As an organization member, I want a plain-language explanation of the built-in roles, so that I can understand who should receive each responsibility without reverse-engineering a checkbox matrix.
- Given
/settings/roles, when the page loads, then a localized role guide lists Owner, Admin, Manager, Member, and Viewer with explicit “Can” and “Cannot” summaries before the detailed matrix. - Given the role guide, when a reader compares a built-in role, then the copy describes the centralized capability matrix and does not claim to replace feature-specific policy boundaries.
- Given
3. Scope Boundaries
- In:
- A code-defined capability catalog (
Permissionregistry) — stable string keys grouped by domain, e.g.crm.deal.edit,crm.deal.delete,chaos.import.create,chaos.import.save,work.task.assign,billing.manage. Capabilities are defined in code, never user-invented. - Org-scoped roles built on the five system presets (
is_system=True, not deletable) plus custom roles an owner/admin can create, each mapping to a subset of catalog capabilities. - Per-member role assignment (extends the existing
OrganizationMember.role). - One centralized backend resolver
has_permission(member, "key")+ a DRFHasCapability("key")permission class and aserializer/endpoint exposing the current user’s effective capabilities. - One simple frontend: a Settings → Roles & Permissions matrix (role × capability checkboxes),
a
usePermissions()hook, and a<Can capability="…">gate component used across features. - First adopters wired in the same epic: F70 input/save gate and F50 deal edit/delete.
- Audit (F16) on every permission/role change.
- A code-defined capability catalog (
- Out:
- Object/row-level ACLs (per-deal sharing, “only deals I own”) — that is a later, separate spec.
- Field-level permissions and attribute/condition-based rules (ABAC).
- SSO/SCIM role provisioning, cross-org roles, time-boxed grants — deferred (see F39).
- Changing what the five preset roles mean by default — presets stay backward-compatible.
4. Data Model
Reuses the F02 spine; adds an org-scoped role layer. All new models extend UUIDModel with a
required organization FK (Rule 1).
Role—organizationFK,key(slug, unique per org),name,is_system(bool; the five presets),description. System roles are seeded per org and cannot be deleted or renamed.- Starter custom roles — seeded, non-system roles added idempotently for every org so the matrix is usable immediately. Initial set: an import-focused operator role, a sales-lead role, a project-coordinator role, and an access-admin role built from the code-owned capability catalog. Owners/admins may edit or delete these later.
RoleCapability—organizationFK,roleFK,capability(string key from the code catalog). Unique(role, capability). The grant set of a role.- Capability catalog — not a table: a code-defined registry (
apps/permissions/catalog.py) listing every valid key + human label + domain group, validated on save (an unknown key is rejected, never stored — Rule: no invented capabilities). OrganizationMember(F02, extended) — keep the existingroleCharField for backward-compat and add a nullablecustom_roleFK →Role. Effective role =custom_roleif set, else the preset matchingrole. No destructive migration; a data migration seeds the five presetRolerows per org and maps existingrolestrings to them.- Status/transition diagrams: none (no lifecycle). Update backend/DIAGRAMS.md
ER section for the new
Role/RoleCapabilityrelations + the resolution path.
5. API Contract
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| GET | /api/permissions/catalog/ | Member | — | 200 [{key, label, group}] | — |
| GET | /api/permissions/me/ | Member | — | 200 {capabilities: [key,…]} (current user’s effective set) | — |
| GET | /api/roles/ | Member | — | 200 [{id, key, name, is_system, capabilities[]}] | — |
| GET | /api/roles/{id}/ | Member | — | 200 {id, key, name, is_system, capabilities[]} | 404 cross-org |
| POST | /api/roles/ | roles.manage | {name, capabilities[]} | 201 {role} | 400 unknown capability; 403 |
| PATCH | /api/roles/{id}/ | roles.manage | {name?, capabilities[]?} | 200 {role} | 403; 404 cross-org; 409 system-role rename; 400 unknown capability |
| DELETE | /api/roles/{id}/ | roles.manage | — | 204 | 403; 409 system role / role in use |
| POST | /api/members/{id}/role/ | members.manage | {role? , custom_role_id?} | 200 {member} | 403; 404 cross-org |
The exact POST /api/roles/ surface is owned by RoleViewSet.create with collected proof
F73.AC001.ROLE_CREATE; backend execution remains subject to the guarded command recorded in
F73.GAP007, and collection is not runtime authorization evidence.
- Tenant scoping: every
Role/RoleCapability/member query filtered byorganization_id=self.request.organization_id; no endpoint accepts a cross-org id (Rule 1). - Guardrails: an org must always retain at least one member with
roles.manage+members.manage(cannot lock yourself out — owner preset always holds all capabilities and cannot be reduced below the lockout floor).roles.manageandmembers.manageare owner/admin presets by default. - No AI output → no
EvidenceSource(n/a).
6. UI / UX
- Route:
/settings/roles(under Settings). Page componentRolesPermissionsPage. - Primary UI: a capability matrix — rows = capabilities grouped by domain (CRM, Work, Chaos Import, Billing…), columns = roles; cells = checkboxes. System-preset columns show a lock affordance for capabilities that can’t drop below the lockout floor. Seeded starter custom roles appear beside the presets so the owner begins from examples rather than an empty canvas. “New role” creates a custom column.
- Role guide: before the matrix, the page shows a localized comparison table for the five immutable system presets with short “Can” and “Cannot” summaries. It is an orientation aid only; the matrix remains the source of truth for capability-level access, and custom roles are not summarized by the built-in rows.
- States: empty (only presets, prompt to add a custom role), loading (skeleton matrix), populated,
error, no-permission (members without
roles.managesee a read-only view of their own capabilities). - Reusable enforcement primitives (the “simple frontend”):
usePermissions()→{ can(key): boolean }, backed byGET /api/permissions/me/via TanStack Query.<Can capability="crm.deal.delete">…</Can>— renders children only if allowed; the single pattern every feature uses to hide controls. No more ad-hocrole === 'admin'checks.
- shadcn:
Table/DataTable,Checkbox,Switch,Card,Button,Dialog(new role),Badge,Tooltip(lock reason). Checkfrontend/src/components/ui/first (Rule 4). - i18n namespace:
roles— keys in bothenandvi(vi first per F18); capability labels also localized. No hardcoded strings (Rule 3). - DESIGN.md settings surface + TASTE.md pre-flight apply.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Toggle a capability → role’s members gain/lose access on the gated endpoint | backend | backend/tests/test_api.py::TestRolesPermissions |
| 2 | Cross-tenant isolation — roles/capabilities scoped to owning org only | backend | backend/tests/test_multitenancy.py |
| 3 | Backward-compat — five presets resolve to pre-F73 effective access | backend | backend/tests/test_api.py |
| 4 | Unknown capability key rejected (not stored) | backend | backend/tests/test_api.py |
| 5 | Lockout floor — cannot remove the last roles.manage/members.manage holder | backend | backend/tests/test_api.py |
| 6 | HasCapability enforced on a real adopter (F70 save, F50 deal delete) | backend | backend/tests/test_api.py |
| 7 | Audit entry written on every role/capability change | backend | backend/tests/test_api.py |
| 8 | Matrix toggle → control hidden + route no-permission state | e2e | frontend/tests/e2e/lumi.spec.ts describe("Roles & Permissions") |
Backend coverage ≥99%; new E2E describe block required.
8. Current Behavior
F73’s capability layer is present: Role and RoleCapability are organization-scoped, preset and
starter custom roles are seeded idempotently, effective_capabilities() resolves custom or preset
grants, and HasCapability/member_has_capability gates the first adopter paths. The web surface
uses RolesPermissionsPage, usePermissions(), and <Can>, and now leads with a localized guide
to the five built-in roles before the detailed matrix. Legacy organization permission classes remain
as compatibility adapters, and broader endpoint migration is not yet complete.
9. Error States
- Forbidden action →
403 {detail: "roles.error_permission_denied"}; the frontend hides the control in the first place, so a 403 indicates a direct API call or a stale client — surface a toast, refetchpermissions/me. - Unknown capability key on role save →
400 {detail: "roles.error_unknown_capability"}; never stored. - System-role mutation (rename/delete preset) →
409 {detail: "roles.error_system_role"}. - Lockout attempt →
409 {detail: "roles.error_lockout"}— the org must keep one admin able to manage roles/members. - Never silently widen access on error; default-deny on any resolution failure.
10. Diagrams & Docs Touched
- backend/DIAGRAMS.md: ER for
Role/RoleCapability+ the member→role→capability resolution path; note the newHasCapabilitypermission class. - frontend/DIAGRAMS.md:
/settings/rolesroute;<Can>/usePermissionsflow. - GLOSSARY.md: Capability, Role (custom vs system preset), Capability catalog.
- F02 spec: cross-reference — F73 is the granular layer over F02 roles.
- ADR-012 — Capability-based RBAC over preset roles (Accepted) — the model of record for this feature.
11. Implementation Audit (2026-06-17)
Built and source-audited for web. The repository contains the following intended verification commands, but this fresh worktree does not claim their execution:
cd backend && ./.venv/bin/python -m pytest tests/test_solid.py -k "test_list_roles or test_me_owner_all_caps" -qpnpm --dir frontend exec vitest run src/test/F73Permissions.test.tsxpnpm --dir frontend exec playwright test tests/e2e/lumi.spec.ts -g "Roles & Permissions" --project=chromiumpnpm --dir frontend exec tsc --noEmit
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Capability catalog, system/custom roles, OrganizationMember.custom_role, and HasCapability backend gate are implemented | ✅ | backend/apps/permissions/; backend/apps/organizations/permissions.py; backend/tests/test_solid.py |
| 2 | Preset roles preserve backward-compatible access while supporting custom capability grants | ✅ | backend/apps/permissions/catalog.py; backend/tests/test_solid.py::TestRolesPermissions |
| 3 | Every org gets starter custom roles so owners do not face a blank RBAC setup | ✅ | backend/apps/permissions/services.py::seed_starter_custom_roles_for_org; backend/apps/organizations/views.py; backend/apps/accounts/management/commands/seed_demo_lumi.py; backend/tests/test_solid.py |
| 4 | Web roles matrix, usePermissions(), and <Can> enforcement are implemented | ✅ | frontend/src/pages/settings/RolesPermissionsPage.tsx; frontend/src/hooks/queries/usePermissions.ts; frontend/src/components/Can.tsx; frontend/src/test/F73Permissions.test.tsx |
| 5 | Web behavior covers owner starter roles, member read-only capabilities, and viewer Chaos Import denial | ⚠️ registered, execution unverified | frontend/tests/e2e/lumi.part03.spec.ts describe("Roles & Permissions") |
| 6 | Legacy orgs missing preset roles are backfilled idempotently when the roles surface is queried | ✅ | backend/apps/permissions/views.py::RoleViewSet.get_queryset; Playwright run against seeded demo org |
Remaining gaps (ordered):
- Native shells — iOS/Android/Mac do not expose the F73 roles-management surface yet; platform cells remain
🔲. - Adoption breadth — more features still need to migrate from legacy role checks to
HasCapability/<Can>beyond the first adopters. - Runtime evidence — pytest, Vitest, and Playwright results must be retained from their target environments; source and collection are not execution proof.
12. Open Questions / Decisions
- ADR ratified: ADR-012 — Capability-based RBAC over preset roles
(Accepted 2026-06-15) — capability-grant RBAC with a code-owned catalog + per-org custom roles over the
five immutable presets;
HasCapabilityis the single backend gate;usePermissions()/<Can>the only frontend primitives; ABAC/row-level ACLs deferred. Implementation follows that ADR. Cross-ref F02 and the F39 security audit. - Should custom roles be available on all plans or gated to a paid tier? (Pricing decision — defer to F53; default: presets free, custom roles on Business.)
- Team-scoped capabilities (a role that applies only within a team) — out for v1, note as future.
- How
IsTeamAdminfolds into the capability model (team-level capabilities) — defer to v2.
13. Bug / Change Log
2026-08-25 — Issue #109 role guide added
Type: Product clarity
Summary:
- Added a localized five-role “Can / Cannot” guide to the existing web
/settings/rolessurface. - Kept the capability matrix as the authorization source of truth and explicitly bounded the guide away from feature-specific policy claims.
- Added Vitest and Playwright acceptance coverage; native roles management remains the separate F73.GAP004 boundary.
2026-07-16 — Contract Graph audit reconciled shipped code and evidence
The implementation audit now reflects the capability engine and web primitives actually present in the repository. The stale fixed-role-only §8 description and unexecuted live-web claim were corrected; native surface, broader adoption, and runner/runtime evidence remain explicit gaps.
2026-07-02 — Stale test-class reference corrected (line ~251)
Type: Documentation Fix
Summary:
- Line ~251 previously referenced
tests/test_solid.py::TestF73RBAC, a class that does not exist intest_solid.py. The actual RBAC test class isTestRolesPermissions(verified by grep:test_solid.py:579).TestF73CoverageCompletionon the same line was correct and is unchanged.
2026-07-02 — Spec drift fix: check 6 confirmed-shipped ACs in §2
Type: Documentation truth fix
Summary:
- AC “seeded starter roles present on first open” checked (
[x]): confirmed by §11 row 3 ✅ (seed_starter_custom_roles_for_org) and row 6 ✅ (legacy org idempotent backfill viaRoleViewSet.get_queryset). - AC “toggle capability → 403 + control hidden” checked (
[x]): confirmed by §11 rows 1 ✅ (capability catalog +HasCapabilitybackend gate) and 4 ✅ (web roles matrix +<Can>enforcement) and 5 ✅ (live Playwright run). - AC “custom role with only chaos.import.create can open /intel/import but not save” checked (
[x]): confirmed by §11 row 1 ✅ (HasCapability) and §13 entry “F70 chaos save rewired to requirechaos.import.save(first adopter)”. - AC “member without chaos.import.create sees no-permission state” checked (
[x]): confirmed by §11 row 5 ✅ (“viewers are blocked from Chaos Import” — live Playwright). - AC “capability-gated controls hidden + route shows no-permission state” checked (
[x]): confirmed by §11 rows 4 ✅ (<Can>renders children only if allowed) and 5 ✅ (live web behavior). - AC “preset roles backward-compatible” checked (
[x]): confirmed by §11 row 2 ✅ (backward-compatible access while supporting custom capability grants). - Remaining
[ ]AC: “F16 audit entry on every capability change” — §11 has no row confirming this is implemented; stays unchecked.
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /start (authenticated import) -> /intel/import (app-wide IA restructure; API paths unchanged).
2026-06-15 — Backend shipped (permissions app)
Type: Feature
Summary:
- New
apps/permissions: capability catalog,Role/RoleCapabilitymodels (+migrations),OrganizationMember.custom_roleFK, preset-seeding data migration,effective_capabilitiesresolver,HasCapability/member_has_capabilitygate, roles + permissions + member-role API. F70 chaossaverewired to requirechaos.import.save(first adopter).IsOrgOwnerOrAdmin/IsOrgManagerre-expressed as capability checks (backward-compatible).
Reason:
- Implements ADR-012; backend half of F73.
Impact:
- MVP_SCOPE F73 Backend → ✅ (overall 🚧 pending frontend). Presets reproduce pre-F73 access (proven by
backward-compat tests). Frontend (
<Can>, /settings/roles) tracked separately.
Tests:
tests/test_solid.py::TestRolesPermissions+TestF73CoverageCompletion; cross-tenant in test_multitenancy. Full backend gate green at 99.01% coverage (2687 passed).
Notes:
MemberRoleSerializeraccepts bothrole(preset) andcustom_role_idper spec §5.
2026-06-17 — Web verification + legacy-org preset backfill
Type: Fix
Summary:
- Verified the web RBAC surface end to end and fixed a real live bug where older orgs with no seeded system roles rendered an empty
/settings/rolesmatrix. - Seeded starter custom roles so owners are not forced to invent their first RBAC model from scratch.
Reason:
- The spec and registry still treated F73 web/E2E as incomplete, but the code already existed. Live Playwright exposed one real gap: the roles page did not self-heal older orgs created before preset seeding landed.
Impact:
- Opening the roles surface now idempotently seeds the five system presets for legacy orgs, so owners see the real matrix instead of an empty page.
- New orgs and seeded demo orgs also receive starter custom roles (
import-operator,sales-lead,project-coordinator,access-admin) immediately.
2026-06-17 — Starter-role catalog expanded
2026-06-18 — Preset/starter seeding hardened for stale org state
Type: Fix
Summary:
- Hardened the RBAC seed helpers so opening the roles surface reconciles missing preset capability rows even when legacy orgs already have partial system-role records.
- Added backend regression coverage for partial preset backfill and starter-role idempotence.
Reason:
- The starter-role concept was already implemented, but one stale-org edge remained: an org could carry pre-existing system role rows without the full capability grants, leaving the matrix under-seeded.
Impact:
- F73 remains shipped on web, but the seed path is now truly idempotent for both role rows and their capability grants.
- Owners landing on
/settings/rolesget the intended presets/starters even if earlier data was incomplete.
Tests:
cd backend && ./.venv/bin/python -m pytest tests/test_solid.py -k "test_roles_list_backfills_missing_preset_capabilities or test_seed_starter_custom_roles_is_idempotent" -q
Type: UX improvement
Summary:
- Expanded the seeded RBAC starter-role catalog so owners do not face a nearly blank configuration problem on first open.
- Kept the model conservative and capability-honest: every starter role is composed only from already-shipped catalog capabilities.
Impact:
- Every newly seeded org now receives four opinionated custom starters instead of two:
import-operator,sales-lead,project-coordinator, andaccess-admin. - The roles surface offers better first-run examples for delegation across import, work coordination, sales execution, and access administration.
Tests:
cd backend && ./.venv/bin/python -m pytest tests/test_solid.py -k "starter_custom_roles" -q- Web E2E now proves owner, member, and viewer behavior on the live app surface.
Tests:
cd backend && ./.venv/bin/python -m pytest tests/test_solid.py -k "test_list_roles or test_me_owner_all_caps" -qpnpm --dir frontend exec vitest run src/test/F73Permissions.test.tsxpnpm --dir frontend exec playwright test tests/e2e/lumi.spec.ts -g "Roles & Permissions" --project=chromiumpnpm --dir frontend exec tsc --noEmit
2026-06-15 — Spec created (RBAC scope change)
Type: Feature
Summary:
- Created F73 to centralize authorization into a capability-based RBAC layer over F02’s five roles:
code-defined capability catalog, org-scoped system + custom roles, a
HasCapabilitybackend gate, and a simple matrix UI +<Can>/usePermissionsfrontend primitives.
Reason:
- Triggered by the F70 question “should the Import link be owner-only?” and the CRM need for edit/delete-deal granularity. Hardcoded role checks don’t scale; the owner needs one place to set “who can do what.” Authorized as a Rule-6 scope change (un-strikes “Advanced RBAC” in MVP_SCOPE).
Impact:
- New draft feature F73 at status
🔲 Not started. Strictly additive; presets stay backward-compatible. Structural — requires an ADR before code. No code shipped by this spec.
Tests:
- Test plan defined (§7); not yet implemented.
Notes:
- Build engine + presets first (zero behavior change), then migrate gates feature-by-feature. Capability keys are code-owned; never let users invent capabilities. Default-deny on resolution failure.
The exact GET /api/permissions/catalog/ read surface is registered to capability_catalog with
F73.AC001.CATALOG_API; repository evidence does not establish browser, native, live-provider,
Redis, Sentry, or production authorization behavior.
The exact GET /api/permissions/me/ read surface is registered to my_capabilities with
F73.AC001.ME_API; repository evidence does not establish browser, native, live-provider, Redis,
Sentry, or production authorization behavior.