Files
LynkedUpPro_CRM/docs/superpowers/plans/2026-05-29-plan8-abac-routing.md
T

309 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan 8 — ABAC Access Control + Unified Routing
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`). Follow the execution protocol (`2026-05-29-execution-protocol.md`) — sequential on shared files, parallel only where lanes are disjoint.
**Goal:** Generalize the current RBAC into an **attribute-based access-control (ABAC) policy engine** — decisions over subject/resource/action/context attributes, ordered permit/deny, **deny-wins, default-deny** — exposed as a resource-aware `canAccess(action, resource, ctx?)` (with the existing `can(module, action)` kept, engine-backed). Add a permission-aware **route guard** + styled **Access Restricted** page, **hide nav** a subject can't view, and (Phase C) **consolidate the triplicated `/owner|/admin|/emp-fa/*` routes into one unified Option-B path per feature**.
**Architecture:** A new pure `src/data/abac.js` engine + `compilePolicies()` that folds the existing `orgPermissions` + `userPermissionOverrides` into policies and adds hand-written **resource-aware** rules (a rep edits only assigned projects; a sub views only their projects; a canvasser edits only leads they sourced). `usePermissions` compiles policies once and exposes both `can` (module, kept) and `canAccess` (resource-aware). `ProtectedRoute` gains a `module` prop → denied renders `<AccessRestricted/>`. Nav filters by `can(module,'view')`. Phase C swaps the triplicated routes for unified paths + redirects, fixes the 4 `basePath` builders, and rewrites nav links.
**Tech Stack:** React 18, React Router, Vite 7, mock React-context store, pnpm. No test harness → engine fixture-tested via throwaway `node`; UI via `npm run build` + dev-run.
**Spec/master:** `docs/superpowers/2026-05-29-MASTER-PLAN.md` Phase 8 (§8), Appendix A.8 (ABAC schema), §15 (routing map), Appendix G Plan 8.
---
## Current state (verified)
- **`usePermissions` (`src/hooks/usePermissions.js`):** `can(moduleKey, action)` + `canAny`/`canAll` + `orgRoleKey`. Reads `orgPermissions`, `orgMembers`, `userPermissionOverrides`. OWNER → `true` short-circuit. Override lookup `find(o => o.userId===user.id && o.moduleKey===m && o.action===a)`. `resolveOrgRoleKey` (`src/utils/permissions.js`) maps auth role → org key; `FIELD_AGENT→CANVASSER`.
- **`PermissionGate` (`src/components/PermissionGate.jsx`):** `{ module, action|any|all, fallback }`.
- **Data (`mockStore.jsx`):** `orgPermissions` (modules: `jobs, financials, commission, team, leads, leaderboard, subcontractor_tasks`; roles: `OWNER, ADMIN, SALES_REP, CANVASSER, SUBCONTRACTOR`) + `PERMISSION_MODULES` + `userPermissionOverrides` ([{userId,moduleKey,action,effect}]) + mutators `updateOrgPermissions`/`setUserPermissionOverride`/`removeUserPermissionOverride`/`clearUserPermissionOverrides`. **No module covers maps/dispatch/storm-intel/pro-canvas/estimates/settings.**
- **`ProtectedRoute` (`App.jsx` ~50):** `allowedRoles` only (raw `user.role`); no `usePermissions`; denied → `<Navigate to="/" replace/>` (silent; no Access Restricted page).
- **Triplicated routes:** maps, pro-canvas, estimates, kanban, estimate(builder), leads/:leadId, settings, subcontractor-tasks, dispatch, storm-intel — under `/owner`, `/admin`, `/emp/fa`. Already-unified: `/projects/:id`, `/leads/view/:leadId`, `/lead-verification`, `/profile`, `/field/storm-zones`, `/admin/leaderboard`, `/admin/schedule`.
- **`basePath`** = inline ternary in **4 files**: `kanban/LeadInfoDrawer.jsx:148`, `estimates/EstimateDetailModal.jsx:205`, `EstimatesPage.jsx:235`, `LeadProjectPage.jsx:302` (build `${basePath}/leads|/estimate|/kanban`).
- **Nav (`Layout.jsx` `getNavItems()` ~126-215):** per-role `{to,icon,label}` arrays, mixed prefixes, no permission hiding.
- **Org Settings (`OrgSettings.jsx`):** `AccessControlMatrix` (iterates `PERMISSION_MODULES` × `orgRoles`, toggles via `updateOrgPermissions`) + `AccessControlPersonModal` (per-user overrides). **It already drives the data the engine will consume — so adding modules flows into it automatically.**
- **Resource attributes:** project `ownerId`, `subcontractorIds[]`, `teamMembers[].userId`, `contractorId` (NO top-level `salesRepId` — rep is a teamMember with jobRole 'Sales Rep'); kanban lead `assignedAgentId`; sub task `subcontractorId`; **primary `leads` use `assignedTo` (name string) — needs `assignedAgentId` for ABAC.** `filterProjectsForUser` util already exists.
## Scope & risk note (read first)
- **Phases AB (ABAC engine + guard + Access Restricted + nav hiding + resource rules)** are **additive and low-risk** — the demo "wow" (realistic enterprise access). They can ship and be reviewed independently.
- **Phase C (route URL consolidation)** is an **app-wide rewire** (every route + 30+ nav links + 4 `basePath` builders) for mostly-cosmetic URLs. It is the **risky** part. It includes **redirects from old paths** so nothing breaks. **Execute + review Phase C as its own checkpoint, or defer it** — Phases AB deliver the access story without it.
---
## Lane / ownership map
| Task | Lane (exclusive files) | Phase |
|------|------------------------|-------|
| T1 Extend modules + `assignedAgentId` on leads | `src/data/mockStore.jsx` | A |
| T2 ABAC engine (new) | `src/data/abac.js` | A (parallel — new file) |
| T3 `usePermissions` resource-aware `canAccess` | `src/hooks/usePermissions.js` | B (after T1+T2) |
| T4 Route guard + Access Restricted page | new `src/pages/AccessRestricted.jsx`, `src/App.jsx` (ProtectedRoute) | B (after T3) |
| T5 Resource-aware enforcement in pages | `src/pages/owner/OwnerProjectDetail.jsx`, `src/pages/subcontractor/SubcontractorProjectDetailPage.jsx` | B (after T3) |
| T6 Nav permission-hiding | `src/components/Layout.jsx` | B (after T3) |
| **— Phase C (higher risk; separate checkpoint) —** | | |
| T7 Unified routes + redirects | `src/App.jsx` | C |
| T8 `basePath` → absolute paths | 4 link-builder files | C |
| T9 Nav unified paths | `src/components/Layout.jsx` | C |
| T10 Verify | (read-only) | D |
**A:** T1 (mockStore) + T2 (new abac.js) parallel. **B:** T3 then T4/T5/T6 (disjoint files, parallel). **C:** T7→T8→T9 sequential-ish (T7 owns App.jsx; T9 owns Layout — but T6 also edits Layout, so do T6 before Phase C, or fold T9 into T6's file under sequencing). **D:** verify.
---
## Task 1: Extend permission modules + add `assignedAgentId` to leads
**Files:** `src/data/mockStore.jsx` only.
- [ ] **Step 1 — add the modules that routes need.** Extend `PERMISSION_MODULES` and `orgPermissions` (the `useState` initial) with these keys so every gated route maps to a module (use sensible role defaults — OWNER/ADMIN full, others scoped):
```js
// add to PERMISSION_MODULES:
dispatch: { label: 'LynkDispatch', actions: ['view','assign'] },
storm_intel: { label: 'Storm Intel', actions: ['view'] },
territory_map: { label: 'Territory Map', actions: ['view'] },
estimates: { label: 'Estimates', actions: ['view','edit'] },
pro_canvas: { label: 'ProCanvas', actions: ['view'] },
settings: { label: 'Org Settings', actions: ['view','edit'] },
```
And the matching `orgPermissions` rows, e.g.:
```js
dispatch: { OWNER:['view','assign'], ADMIN:['view','assign'], SALES_REP:['view'], CANVASSER:[], SUBCONTRACTOR:[] },
storm_intel: { OWNER:['view'], ADMIN:['view'], SALES_REP:['view'], CANVASSER:['view'], SUBCONTRACTOR:[] },
territory_map: { OWNER:['view'], ADMIN:['view'], SALES_REP:['view'], CANVASSER:['view'], SUBCONTRACTOR:[] },
estimates: { OWNER:['view','edit'], ADMIN:['view','edit'], SALES_REP:['view','edit'], CANVASSER:[], SUBCONTRACTOR:[] },
pro_canvas: { OWNER:['view'], ADMIN:['view'], SALES_REP:['view'], CANVASSER:['view'], SUBCONTRACTOR:[] },
settings: { OWNER:['view','edit'], ADMIN:['view'], SALES_REP:[], CANVASSER:[], SUBCONTRACTOR:[] },
```
(Pipeline/kanban + project detail map to the existing `jobs` module; leaderboard/leads/subcontractor_tasks already exist.)
- [ ] **Step 2 — `assignedAgentId` on primary leads.** In `seedLeadsFromAttribution()`, add `assignedAgentId: <e#>` to each seeded lead (derive from the canvasser — map canvasserName→e-id, or assign round-robin e1e5) so ABAC lead rules can reference an id, not a name. Keep `assignedTo`/`canvasserName` as-is.
- [ ] **Step 3 (verify):** `npm run build` → `✓ built`, 0 dup keys. `git grep -c "assignedAgentId" -- src/data/mockStore.jsx` increased; the 6 new module keys present in both `PERMISSION_MODULES` and `orgPermissions`.
- [ ] **Step 4:** commit `git commit -am "feat(data): extend permission modules to cover all routed features + add assignedAgentId to leads"`.
## Task 2: ABAC policy engine (`src/data/abac.js`)
**Files:** Create `src/data/abac.js`. **Parallel with T1.**
- [ ] **Step 1:** Create the engine:
```js
// src/data/abac.js
// Pure attribute-based access-control engine. A decision evaluates ordered policies
// over { subject, action, resource, ctx }. DENY WINS; DEFAULT DENY.
// A policy: { id, effect: 'permit' | 'deny', when: (subject, action, resource, ctx) => boolean }
export function evaluate(policies, subject, action, resource = {}, ctx = {}) {
let permitted = false;
let denied = false;
for (const p of policies) {
let match;
try { match = p.when(subject, action, resource, ctx); } catch { match = false; }
if (!match) continue;
if (p.effect === 'deny') denied = true;
else permitted = true;
}
return !denied && permitted; // deny wins; default deny
}
// Build the subject (attributes) from the auth user + org role key.
export function buildSubject(user, orgRoleKey) {
return {
id: user?.id,
role: user?.role,
orgRole: orgRoleKey, // OWNER | ADMIN | SALES_REP | CANVASSER | SUBCONTRACTOR
team: user?.team,
territory: user?.territory,
};
}
// Normalize a resource arg into { module, type, ...attrs } the policies read.
// Accepts a domain object (project/lead/task) OR a plain { module } for route/module checks.
export function normalizeResource(resource) {
if (!resource) return { module: undefined };
// a project
if (resource.id && (resource.ownerId || resource.subcontractorIds || resource.teamMembers)) {
return {
module: 'jobs', type: 'project',
ownerId: resource.ownerId,
subcontractorIds: resource.subcontractorIds || [],
teamUserIds: (resource.teamMembers || []).map(t => t.userId),
status: resource.status, value: resource.budget,
};
}
// a lead
if (resource.assignedAgentId !== undefined || resource.columnId !== undefined) {
return { module: 'leads', type: 'lead', assignedAgentId: resource.assignedAgentId };
}
// a subcontractor task
if (resource.subcontractorId !== undefined && resource.title !== undefined) {
return { module: 'subcontractor_tasks', type: 'task', subcontractorId: resource.subcontractorId };
}
// already a {module} (route/module-level check)
return { module: resource.module || resource.type, type: resource.type };
}
// Compile the RBAC data + resource-aware rules into an ordered policy list.
// orgPermissions: { module: { ROLE: [actions] } }; overrides: [{userId, moduleKey, action, effect}]
export function compilePolicies(orgPermissions = {}, overrides = []) {
const policies = [];
// 1) Blanket OWNER permit (owners see/do everything).
policies.push({ id: 'owner-all', effect: 'permit', when: (s) => s.orgRole === 'OWNER' });
// 2) Baseline role×module permits from the matrix.
for (const [module, roleMap] of Object.entries(orgPermissions)) {
for (const [role, actions] of Object.entries(roleMap)) {
for (const action of actions) {
policies.push({
id: `base:${module}:${role}:${action}`,
effect: 'permit',
when: (s, a, r) => s.orgRole === role && (r.module === module) && a === action,
});
}
}
}
// 3) Resource-aware rules (the ABAC layer).
// Sales rep may EDIT a project only if assigned (on the team) or it's nominally theirs.
policies.push({
id: 'rep-edit-assigned-only',
effect: 'deny',
when: (s, a, r) => s.orgRole === 'SALES_REP' && r.module === 'jobs' && a === 'edit'
&& r.type === 'project' && !(r.teamUserIds || []).includes(s.id),
});
// Subcontractor may VIEW only projects they're on.
policies.push({
id: 'sub-view-own-projects',
effect: 'permit',
when: (s, a, r) => s.orgRole === 'SUBCONTRACTOR' && r.module === 'jobs' && a === 'view'
&& r.type === 'project' && (r.subcontractorIds || []).includes(s.id),
});
// Canvasser may EDIT only leads they sourced.
policies.push({
id: 'canvasser-edit-own-leads',
effect: 'permit',
when: (s, a, r) => s.orgRole === 'CANVASSER' && r.module === 'leads' && a === 'edit'
&& r.type === 'lead' && r.assignedAgentId === s.id,
});
// 4) Per-person overrides (grant/deny) — deny wins via evaluate().
for (const o of overrides) {
policies.push({
id: `override:${o.userId}:${o.moduleKey}:${o.action}:${o.effect}`,
effect: o.effect === 'deny' ? 'deny' : 'permit',
when: (s, a, r) => s.id === o.userId && r.module === o.moduleKey && a === o.action,
});
}
return policies;
}
```
- [ ] **Step 2 (fixture-verify):** throwaway `node` check — assert: OWNER permits anything; a SALES_REP `can('view','jobs')` baseline permit but `canAccess('edit', project)` is DENIED when not on the team and PERMITTED when on it; a SUBCONTRACTOR is permitted `view` only on a project whose `subcontractorIds` includes them; a deny override beats a baseline permit (deny-wins); default-deny for an unknown module. Delete the script.
- [ ] **Step 3:** commit `git commit -m "feat(abac): pure policy engine (deny-wins, default-deny) + compilePolicies folding RBAC + resource rules"` (after `git add src/data/abac.js`).
## Task 3: Resource-aware `canAccess` in `usePermissions`
**Files:** `src/hooks/usePermissions.js` only. **After T1+T2.**
- [ ] **Step 1:** Import the engine. Compile policies once: `const policies = useMemo(() => compilePolicies(orgPermissions, userPermissionOverrides), [orgPermissions, userPermissionOverrides]);` and `const subject = useMemo(() => buildSubject(user, orgRoleKey), [user, orgRoleKey]);`.
- [ ] **Step 2:** **Keep `can(moduleKey, action)`** (so all existing callers + `PermissionGate` keep working) but back it with the engine: `const can = useCallback((moduleKey, action) => evaluate(policies, subject, action, { module: moduleKey }), [policies, subject]);`. Keep `canAny`/`canAll` delegating to `can`.
- [ ] **Step 3:** Add the resource-aware form: `const canAccess = useCallback((action, resource, ctx) => evaluate(policies, subject, action, normalizeResource(resource), ctx), [policies, subject]);`. Return `{ can, canAny, canAll, canAccess, orgRoleKey }`.
- [ ] **Step 4 (verify):** `npm run build` → `✓ built`. Existing `can('leaderboard','view')` callers + `PermissionGate` still behave (OWNER true; matrix respected; overrides respected). `canAccess` available.
- [ ] **Step 5:** commit `git commit -am "feat(perms): resource-aware canAccess backed by the ABAC engine; can() now engine-backed (back-compatible)"`.
## Task 4: Route guard + Access Restricted page
**Files:** Create `src/pages/AccessRestricted.jsx`; edit `src/App.jsx` (ProtectedRoute). **After T3.**
- [ ] **Step 1:** Create `AccessRestricted.jsx` — a styled full-page "Access Restricted" (lock icon, "You don't have permission to view this", a Back / go-to-dashboard button), matching the app's dark theme.
- [ ] **Step 2:** Enhance `ProtectedRoute` to accept an optional `module` (and `action='view'`) prop. Logic: unauthenticated → `/login` (unchanged). Then if `module` is provided, gate via `usePermissions().can(module, action)` and render `<AccessRestricted/>` (NOT a silent redirect) when denied. Keep `allowedRoles` working for role dashboards (if both given, both must pass). (ProtectedRoute is a component — it can call the hook.)
- [ ] **Step 3 (verify):** `npm run build` → `✓ built`. A route given `module="settings"` shows AccessRestricted for a SALES_REP (no settings view) and renders for OWNER/ADMIN.
- [ ] **Step 4:** commit `git commit -am "feat(routes): permission-aware ProtectedRoute (module gate) + Access Restricted page"`.
## Task 5: Resource-aware enforcement in pages (the ABAC demo)
**Files:** `src/pages/owner/OwnerProjectDetail.jsx`, `src/pages/subcontractor/SubcontractorProjectDetailPage.jsx`. **After T3 (parallel with T4/T6).**
- [ ] **Step 1 (OwnerProjectDetail):** pull `canAccess` from `usePermissions`. Gate the project's edit affordances (the inline edit controls / "Advance Stage" etc. added in Plan 4) behind `canAccess('edit', project)` — so a SALES_REP viewing a project they're NOT on sees it read-only, while one on the team can edit. (Use `PermissionGate`-style conditional or a simple `canEdit` flag.) Owner/admin keep full edit (baseline permits + owner-all).
- [ ] **Step 2 (SubcontractorProjectDetailPage):** add an explicit `canAccess('view', project)` gate at the top — if false, render the existing "no access" state. (Combined with Plan 6's `scopedProjectForSub`, this makes the sub-view both scoped AND policy-guarded.)
- [ ] **Step 3 (verify):** `npm run build` → `✓ built`. Dev-run: a sales-rep login on a project they're not a team member of sees read-only (no edit buttons); on their own project, edit shows.
- [ ] **Step 4:** commit `git commit -am "feat(abac): resource-aware edit/view gates on project detail (rep edits only assigned; sub views only theirs)"`.
## Task 6: Nav permission-hiding
**Files:** `src/components/Layout.jsx` only. **After T3.** (Do this BEFORE Phase C's nav-path rewrite to avoid two passes — or combine with T9.)
- [ ] **Step 1:** In `getNavItems()`, tag each nav item with the `module` it maps to (e.g. Projects→`jobs`, Leads→`leads`, LynkDispatch→`dispatch`, Storm Intel→`storm_intel`, Territory Map→`territory_map`, Estimates→`estimates`, ProCanvas→`pro_canvas`, Leaderboard→`leaderboard`, Subcontractor Tasks→`subcontractor_tasks`, Org Settings→`settings`). Dashboards/home/profile/AI assistant stay always-visible.
- [ ] **Step 2:** Filter the returned items with `usePermissions().can(item.module, 'view')` (items without a `module` always show). Now nav hides what a subject can't view.
- [ ] **Step 3 (verify):** `npm run build` → `✓ built`. Dev-run: removing a role's `dispatch.view` in Org Settings hides LynkDispatch from that role's nav.
- [ ] **Step 4:** commit `git commit -am "feat(nav): hide nav items the subject can't view (permission-aware)"`.
---
## — Phase C — Unified routing (HIGHER RISK — separate checkpoint; may be deferred) —
---
## Task 7: Consolidate routes to unified Option-B paths
**Files:** `src/App.jsx` only.
- [ ] **Step 1:** Add the unified routes (one per feature), each `<ProtectedRoute module="<module>">`:
| Unified path | Component | module |
|---|---|---|
| `/territory-map` | Maps | territory_map |
| `/pipeline` | KanbanPage | jobs |
| `/dispatch` | LynkDispatchPage | dispatch |
| `/storm-intel` | StormIntelPage | storm_intel |
| `/estimates` | EstimatesPage | estimates |
| `/estimate` | EstimateBuilder | estimates |
| `/pro-canvas` | ProCanvas | pro_canvas |
| `/leaderboard` | LeaderboardPage | leaderboard |
| `/org-settings` | OrgSettings | settings |
| `/subcontractor-tasks` | SubcontractorTasksPage | subcontractor_tasks |
| `/leads` | LeadsListPage | leads |
| `/leads/new` | CreateLeadPage | leads |
| `/leads/:leadId` | LeadProjectPage | leads |
(`/projects/:id`, `/leads/view/:leadId`, `/lead-verification`, `/profile` already unified — add `module` to their guards: jobs / leads / leads / (none).)
- [ ] **Step 2:** **Replace** the triplicated `/owner|/admin|/emp-fa/*` routes with **redirects** to the unified paths (so old links/bookmarks/nav-not-yet-updated don't break): e.g. `<Route path="/owner/maps" element={<Navigate to="/territory-map" replace/>} />` for each old path. Keep the role dashboards/landings (`/owner/snapshot`, `/admin/dashboard`, `/emp/fa/dashboard`, `/contractor/*`, `/subcontractor/*`, `/vendor/*`, `/portal/*`) as-is. Remove the legacy `/owner/projects/:id` (redirect to `/projects/:id`).
- [ ] **Step 3 (verify):** `npm run build` → `✓ built`. Each feature reachable at its unified path; old paths redirect; denied subjects get Access Restricted (Phase B guard).
- [ ] **Step 4:** commit `git commit -am "feat(routes): unified Option-B feature routes + redirects from legacy role-prefixed paths"`.
## Task 8: Replace `basePath` link-builders with absolute paths
**Files:** `src/components/kanban/LeadInfoDrawer.jsx`, `src/components/estimates/EstimateDetailModal.jsx`, `src/pages/EstimatesPage.jsx`, `src/pages/LeadProjectPage.jsx`.
- [ ] **Step 1:** In each, remove the `const basePath = …` ternary and change the `navigate()` calls to absolute unified paths: `${basePath}/leads/${id}` → `/leads/${id}`; `${basePath}/estimate` → `/estimate`; `${basePath}/kanban` → `/pipeline`. (LeadInfoDrawer's won-lead branch already uses `/projects/...` — keep it; only fix the pre-sale `${basePath}/leads/...`.)
- [ ] **Step 2 (verify):** `npm run build` → `✓ built`. `git grep -rn "basePath" -- src` → none. Dev-run: kanban drawer / estimate / lead-back links land on the unified routes.
- [ ] **Step 3:** commit `git commit -am "refactor(routes): replace basePath role-prefix link-builders with absolute unified paths"`.
## Task 9: Nav unified paths
**Files:** `src/components/Layout.jsx` only.
- [ ] **Step 1:** Update every `to:` in `getNavItems()` to the unified path (Projects→`/projects` stays owner list? use `/projects`... NOTE: there is `/owner/projects` (OwnerProjectList) — keep that as the list or unify to `/projects` list route; if unifying, add a `/projects` list route in T7). Map: Leads→`/leads`, Pipeline→`/pipeline`, LynkDispatch→`/dispatch`, Storm Intel→`/storm-intel`, Territory Map→`/territory-map`, ProCanvas→`/pro-canvas`, Estimates→`/estimates`, Leaderboard→`/leaderboard`, Subcontractor Tasks→`/subcontractor-tasks`, Org Settings→`/org-settings`. Keep dashboards (`/owner/snapshot`, `/admin/dashboard`, etc.).
- [ ] **Step 2 (verify):** `npm run build` → `✓ built`. Nav links land on unified routes for all roles; permission-hiding (T6) still applies.
- [ ] **Step 3:** commit `git commit -am "feat(nav): point nav at unified feature routes"`.
## Task 10: Verify (read-only)
- [ ] `npm run build` clean, 0 dup keys.
- [ ] **ABAC engine:** fixture proofs hold (deny-wins, default-deny, resource rules); `compilePolicies` folds matrix + overrides.
- [ ] **Resource-aware:** a SALES_REP can view a project but cannot edit one they're not on (read-only); can edit one they're on; a SUBCONTRACTOR sees only their projects; toggling a role's module in Org Settings flips both nav visibility and the route guard; a per-person deny override blocks that one person.
- [ ] **Route guard:** a denied direct-URL hit shows `<AccessRestricted/>` (not a silent `/` bounce); owner sees everything.
- [ ] **(Phase C)** every feature has one unified URL; old role-prefixed paths redirect; `basePath` gone; nav uses unified paths.
- [ ] Org Settings matrix edits flow through to live decisions (it edits `orgPermissions` which the engine compiles).
---
## Self-review checklist
- [ ] Modules extended so every routed feature maps to one; `assignedAgentId` on leads.
- [ ] Pure engine (deny-wins/default-deny) + `compilePolicies` (RBAC + resource rules + overrides); fixture-verified.
- [ ] `canAccess` added; `can(module,action)` kept + engine-backed (no caller breakage); `PermissionGate` unchanged.
- [ ] Module-gated `ProtectedRoute` + Access Restricted page (no silent redirect).
- [ ] Resource-aware gates demonstrated on project detail (rep edit / sub view).
- [ ] Nav hides non-viewable items.
- [ ] (Phase C, if executed) unified routes + redirects + `basePath` removed + nav paths updated.
## Notes
- **Phase C is optional/deferrable.** Phases AB + D deliver the ABAC access story (the demo wow) and are independently shippable. Decide at execution time whether the URL consolidation churn is worth it for this demo.
- The folded-in legacy matrix means **Org Settings already configures the engine** — no new policy-editor UI is required (the friendly matrix IS the front-end that compiles to policies). A raw-policy editor is explicitly out of scope (YAGNI).