feat(abac): pure policy engine (deny-wins, default-deny) + compilePolicies folding RBAC + resource rules

This commit is contained in:
Satyam Rastogi
2026-05-29 22:04:04 +05:30
parent c49c0786fd
commit 6ff8ef31b1
+112
View File
@@ -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;
}