# 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 ``. 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 → `` (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 A–B (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 A–B 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: ` to each seeded lead (derive from the canvasser — map canvasserName→e-id, or assign round-robin e1–e5) 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 `` (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 ``:
| 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. `} />` 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 `` (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 A–B + 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).