shipped (web)

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.delete off for the manager role, then managers immediately receive 403 permission_denied on 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/import and submit, but cannot save a brief (needs chaos.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.
  • 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.

3. Scope Boundaries

  • In:
    • A code-defined capability catalog (Permission registry) — 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 DRF HasCapability("key") permission class and a serializer/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.
  • 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 — organization FK, 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 — organization FK, role FK, 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 existing role CharField for backward-compat and add a nullable custom_role FK → Role. Effective role = custom_role if set, else the preset matching role. No destructive migration; a data migration seeds the five preset Role rows per org and maps existing role strings to them.
  • Status/transition diagrams: none (no lifecycle). Update backend/DIAGRAMS.md ER section for the new Role/RoleCapability relations + the resolution path.

5. API Contract

MethodPathAuth/RoleRequestResponseErrors
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—204403; 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 by organization_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.manage and members.manage are owner/admin presets by default.
  • No AI output → no EvidenceSource (n/a).

6. UI / UX

  • Route: /settings/roles (under Settings). Page component RolesPermissionsPage.
  • 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.manage see a read-only view of their own capabilities).
  • Reusable enforcement primitives (the “simple frontend”):
    • usePermissions() → { can(key): boolean }, backed by GET /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-hoc role === 'admin' checks.
  • shadcn: Table/DataTable, Checkbox, Switch, Card, Button, Dialog (new role), Badge, Tooltip (lock reason). Check frontend/src/components/ui/ first (Rule 4).
  • i18n namespace: roles — keys in both en and vi (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

#CaseTypeWhere
1Toggle a capability → role’s members gain/lose access on the gated endpointbackendbackend/tests/test_api.py::TestRolesPermissions
2Cross-tenant isolation — roles/capabilities scoped to owning org onlybackendbackend/tests/test_multitenancy.py
3Backward-compat — five presets resolve to pre-F73 effective accessbackendbackend/tests/test_api.py
4Unknown capability key rejected (not stored)backendbackend/tests/test_api.py
5Lockout floor — cannot remove the last roles.manage/members.manage holderbackendbackend/tests/test_api.py
6HasCapability enforced on a real adopter (F70 save, F50 deal delete)backendbackend/tests/test_api.py
7Audit entry written on every role/capability changebackendbackend/tests/test_api.py
8Matrix toggle → control hidden + route no-permission statee2efrontend/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, refetch permissions/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 new HasCapability permission class.
  • frontend/DIAGRAMS.md: /settings/roles route; <Can>/usePermissions flow.
  • 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" -q
  • pnpm --dir frontend exec vitest run src/test/F73Permissions.test.tsx
  • pnpm --dir frontend exec playwright test tests/e2e/lumi.spec.ts -g "Roles & Permissions" --project=chromium
  • pnpm --dir frontend exec tsc --noEmit
#ClaimStatusEvidence
1Capability 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
2Preset roles preserve backward-compatible access while supporting custom capability grants✅backend/apps/permissions/catalog.py; backend/tests/test_solid.py::TestRolesPermissions
3Every 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
4Web 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
5Web behavior covers owner starter roles, member read-only capabilities, and viewer Chaos Import denial⚠️ registered, execution unverifiedfrontend/tests/e2e/lumi.part03.spec.ts describe("Roles & Permissions")
6Legacy 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):

  1. Native shells — iOS/Android/Mac do not expose the F73 roles-management surface yet; platform cells remain 🔲.
  2. Adoption breadth — more features still need to migrate from legacy role checks to HasCapability / <Can> beyond the first adopters.
  3. 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; HasCapability is 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 IsTeamAdmin folds 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/roles surface.
  • 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 in test_solid.py. The actual RBAC test class is TestRolesPermissions (verified by grep: test_solid.py:579). TestF73CoverageCompletion on 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 via RoleViewSet.get_queryset).
  • AC “toggle capability → 403 + control hidden” checked ([x]): confirmed by §11 rows 1 ✅ (capability catalog + HasCapability backend 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 require chaos.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/RoleCapability models (+migrations), OrganizationMember.custom_role FK, preset-seeding data migration, effective_capabilities resolver, HasCapability/member_has_capability gate, roles + permissions + member-role API. F70 chaos save rewired to require chaos.import.save (first adopter). IsOrgOwnerOrAdmin/IsOrgManager re-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:

  • MemberRoleSerializer accepts both role (preset) and custom_role_id per 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/roles matrix.
  • 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/roles get 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, and access-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" -q
  • pnpm --dir frontend exec vitest run src/test/F73Permissions.test.tsx
  • pnpm --dir frontend exec playwright test tests/e2e/lumi.spec.ts -g "Roles & Permissions" --project=chromium
  • pnpm --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 HasCapability backend gate, and a simple matrix UI + <Can>/usePermissions frontend 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.