24 KiB
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. ReadsorgPermissions,orgMembers,userPermissionOverrides. OWNER →trueshort-circuit. Override lookupfind(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}]) + mutatorsupdateOrgPermissions/setUserPermissionOverride/removeUserPermissionOverride/clearUserPermissionOverrides. No module covers maps/dispatch/storm-intel/pro-canvas/estimates/settings. ProtectedRoute(App.jsx~50):allowedRolesonly (rawuser.role); nousePermissions; 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.jsxgetNavItems()~126-215): per-role{to,icon,label}arrays, mixed prefixes, no permission hiding. - Org Settings (
OrgSettings.jsx):AccessControlMatrix(iteratesPERMISSION_MODULES×orgRoles, toggles viaupdateOrgPermissions) +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-levelsalesRepId— rep is a teamMember with jobRole 'Sales Rep'); kanban leadassignedAgentId; sub tasksubcontractorId; primaryleadsuseassignedTo(name string) — needsassignedAgentIdfor ABAC.filterProjectsForUserutil 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
basePathbuilders) 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_MODULESandorgPermissions(theuseStateinitial) with these keys so every gated route maps to a module (use sensible role defaults — OWNER/ADMIN full, others scoped):And the matching// 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'] },orgPermissionsrows, e.g.:(Pipeline/kanban + project detail map to the existingdispatch: { 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:[] },jobsmodule; leaderboard/leads/subcontractor_tasks already exist.) - Step 2 —
assignedAgentIdon primary leads. InseedLeadsFromAttribution(), addassignedAgentId: <e#>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. KeepassignedTo/canvasserNameas-is. - Step 3 (verify):
npm run build→✓ built, 0 dup keys.git grep -c "assignedAgentId" -- src/data/mockStore.jsxincreased; the 6 new module keys present in bothPERMISSION_MODULESandorgPermissions. - 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:
// 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
nodecheck — assert: OWNER permits anything; a SALES_REPcan('view','jobs')baseline permit butcanAccess('edit', project)is DENIED when not on the team and PERMITTED when on it; a SUBCONTRACTOR is permittedviewonly on a project whosesubcontractorIdsincludes 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"(aftergit 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]);andconst subject = useMemo(() => buildSubject(user, orgRoleKey), [user, orgRoleKey]);. - Step 2: Keep
can(moduleKey, action)(so all existing callers +PermissionGatekeep working) but back it with the engine:const can = useCallback((moduleKey, action) => evaluate(policies, subject, action, { module: moduleKey }), [policies, subject]);. KeepcanAny/canAlldelegating tocan. - 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. Existingcan('leaderboard','view')callers +PermissionGatestill behave (OWNER true; matrix respected; overrides respected).canAccessavailable. - 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
ProtectedRouteto accept an optionalmodule(andaction='view') prop. Logic: unauthenticated →/login(unchanged). Then ifmoduleis provided, gate viausePermissions().can(module, action)and render<AccessRestricted/>(NOT a silent redirect) when denied. KeepallowedRolesworking 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 givenmodule="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
canAccessfromusePermissions. Gate the project's edit affordances (the inline edit controls / "Advance Stage" etc. added in Plan 4) behindcanAccess('edit', project)— so a SALES_REP viewing a project they're NOT on sees it read-only, while one on the team can edit. (UsePermissionGate-style conditional or a simplecanEditflag.) 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'sscopedProjectForSub, 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 themoduleit 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 amodulealways show). Now nav hides what a subject can't view. - Step 3 (verify):
npm run build→✓ built. Dev-run: removing a role'sdispatch.viewin 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-mapMaps territory_map /pipelineKanbanPage jobs /dispatchLynkDispatchPage dispatch /storm-intelStormIntelPage storm_intel /estimatesEstimatesPage estimates /estimateEstimateBuilder estimates /pro-canvasProCanvas pro_canvas /leaderboardLeaderboardPage leaderboard /org-settingsOrgSettings settings /subcontractor-tasksSubcontractorTasksPage subcontractor_tasks /leadsLeadsListPage leads /leads/newCreateLeadPage leads /leads/:leadIdLeadProjectPage leads ( /projects/:id,/leads/view/:leadId,/lead-verification,/profilealready unified — addmoduleto 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 thenavigate()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:ingetNavItems()to the unified path (Projects→/projectsstays owner list? use/projects... NOTE: there is/owner/projects(OwnerProjectList) — keep that as the list or unify to/projectslist route; if unifying, add a/projectslist 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 buildclean, 0 dup keys.- ABAC engine: fixture proofs hold (deny-wins, default-deny, resource rules);
compilePoliciesfolds 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;
basePathgone; nav uses unified paths. - Org Settings matrix edits flow through to live decisions (it edits
orgPermissionswhich the engine compiles).
Self-review checklist
- Modules extended so every routed feature maps to one;
assignedAgentIdon leads. - Pure engine (deny-wins/default-deny) +
compilePolicies(RBAC + resource rules + overrides); fixture-verified. canAccessadded;can(module,action)kept + engine-backed (no caller breakage);PermissionGateunchanged.- 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 +
basePathremoved + 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).