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

25 KiB
Raw Blame History

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.

Cosmetic / frontend-only (read first)

This entire layer is a client-side demonstration of the access model, not a security boundary. The app is frontend-only (mock React-context store, no server — /api/* is stubbed). The real policy enforcement is backend, owned by another team; Plan 8 only makes the access story demoable in the UI:

  • The engine, canAccess, route guard, nav-hiding, and the Org Settings matrix all run in the browser against the mock store.
  • No backend calls, auth tokens, server policy checks, session validation, or "real" enforcement. Frontend gates are presentational (and bypassable) — that is acceptable and intended for the demo.
  • Keep it proportionate: convincing, coherent UI behavior (nav hides, edits go read-only, Access Restricted shows, matrix toggles flip the UI live) over hardened security plumbing. Don't build auth/session/server infrastructure.

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):
    // 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.:
    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:
// 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".

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).