feat(abac): pure policy engine (deny-wins, default-deny) + compilePolicies folding RBAC + resource rules
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user