diff --git a/src/data/abac.js b/src/data/abac.js new file mode 100644 index 0000000..1427a8c --- /dev/null +++ b/src/data/abac.js @@ -0,0 +1,112 @@ +// src/data/abac.js +// Pure attribute-based access-control engine (COSMETIC / frontend-only — a demonstration of the +// access model against the mock store; the real policy enforcement is backend, owned by another team). +// 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 + resolved 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). + 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; +}