Implementing ABAC (Attribute-Based Access Control) for Web Applications
RBAC breaks down when rules emerge like "a user can edit a document if they're the author, the document is in draft status, and the user works in the same organization as the document." Role alone won't help—you need context. ABAC makes decisions based on attributes of the subject (user), object (resource), and environment (time, IP, request context).
How the Model Works
Four entities in ABAC:
Subject—user and their attributes: role, department, clearance_level, org_id.
Resource—object and its attributes: owner_id, status, org_id, classification, region.
Action—read, write, delete, approve.
Environment—time_of_day, ip_address, request_method.
Policy is a predicate over these attributes. For example:
ALLOW IF
subject.org_id == resource.org_id
AND (subject.role == 'editor' OR subject.id == resource.owner_id)
AND resource.status IN ('draft', 'review')
AND action == 'write'
Policy Storage Schema
You can store policies in code (for small rule sets) or in the database with a DSL. Here's a variant storing in PostgreSQL as JSON conditions:
CREATE TABLE abac_policies (
id SERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
effect VARCHAR(8) NOT NULL CHECK (effect IN ('allow', 'deny')),
priority INT NOT NULL DEFAULT 0,
conditions JSONB NOT NULL, -- condition tree
actions TEXT[] NOT NULL,
resources TEXT[] NOT NULL -- glob: 'documents', 'documents/*'
);
-- Example record
INSERT INTO abac_policies (name, effect, priority, conditions, actions, resources)
VALUES (
'editors_can_write_own_draft',
'allow',
10,
'{
"operator": "AND",
"conditions": [
{"attribute": "subject.role", "op": "in", "value": ["editor", "senior_editor"]},
{"attribute": "subject.org_id", "op": "eq", "value": {"ref": "resource.org_id"}},
{"attribute": "resource.status", "op": "in", "value": ["draft", "review"]}
]
}',
ARRAY['write', 'delete'],
ARRAY['documents', 'documents/*']
);
Decision Engine
class ABACEngine {
constructor(policies) {
// Policies preloaded and sorted by priority (deny > allow on conflict)
this.policies = policies.sort((a, b) => {
if (a.effect === 'deny' && b.effect !== 'deny') return -1;
return b.priority - a.priority;
});
}
evaluate(subject, resource, action, environment = {}) {
const context = { subject, resource, action, environment };
for (const policy of this.policies) {
if (!policy.actions.includes(action)) continue;
if (!this.matchesResource(policy.resources, resource.type)) continue;
if (this.evaluateCondition(policy.conditions, context)) {
return policy.effect === 'allow';
}
}
return false; // default deny
}
evaluateCondition(condition, ctx) {
if (condition.operator === 'AND') {
return condition.conditions.every(c => this.evaluateCondition(c, ctx));
}
if (condition.operator === 'OR') {
return condition.conditions.some(c => this.evaluateCondition(c, ctx));
}
if (condition.operator === 'NOT') {
return !this.evaluateCondition(condition.condition, ctx);
}
// Leaf node
const leftVal = this.resolveAttribute(condition.attribute, ctx);
const rightVal = condition.value?.ref
? this.resolveAttribute(condition.value.ref, ctx)
: condition.value;
switch (condition.op) {
case 'eq': return leftVal === rightVal;
case 'neq': return leftVal !== rightVal;
case 'in': return Array.isArray(rightVal) && rightVal.includes(leftVal);
case 'gte': return leftVal >= rightVal;
case 'lte': return leftVal <= rightVal;
case 'contains': return Array.isArray(leftVal) && leftVal.includes(rightVal);
default: return false;
}
}
resolveAttribute(path, ctx) {
// 'subject.org_id' → ctx.subject.org_id
return path.split('.').reduce((obj, key) => obj?.[key], ctx);
}
matchesResource(patterns, resourceType) {
return patterns.some(p =>
p === resourceType || (p.endsWith('/*') && resourceType.startsWith(p.slice(0, -2)))
);
}
}
Integration with Express
const engine = new ABACEngine(await loadPoliciesFromDB());
// Reload policies on change (no server restart)
db.on('policy_changed', async () => {
engine.updatePolicies(await loadPoliciesFromDB());
});
function abac(action) {
return async (req, res, next) => {
const resource = await loadResource(req); // load object with all attributes
const allowed = engine.evaluate(
req.user, // subject
resource, // resource
action, // action
{ // environment
ip: req.ip,
timestamp: Date.now(),
userAgent: req.headers['user-agent'],
}
);
if (!allowed) {
return res.status(403).json({ error: 'Forbidden' });
}
req.resource = resource;
next();
};
}
router.put('/documents/:id', authenticate, abac('write'), updateDocument);
router.delete('/documents/:id', authenticate, abac('delete'), deleteDocument);
Audit Log
ABAC without audit is a blind tool. Every decision is logged:
CREATE TABLE abac_audit_log (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
subject_id INT NOT NULL,
resource_type VARCHAR(128),
resource_id VARCHAR(128),
action VARCHAR(64) NOT NULL,
decision BOOLEAN NOT NULL,
matched_policy_id INT REFERENCES abac_policies(id),
context_snapshot JSONB -- snapshot of subject+resource attrs at decision time
);
CREATE INDEX idx_abac_audit_subject ON abac_audit_log (subject_id, ts DESC);
CREATE INDEX idx_abac_audit_resource ON abac_audit_log (resource_type, resource_id, ts DESC);
This answers the question "why couldn't user X do Y with object Z three days ago"—without it, incident investigation becomes guessing.
Combining with RBAC
Pure ABAC is slower than RBAC with many policies—each check goes through all rules. In practice, use a hybrid: RBAC as first layer (fast coarse check by role), ABAC as second (fine contextual rules where needed).
async function authorize(user, resource, action) {
// Fast RBAC-check: does the role have any access to this resource type?
if (!await rbac.canAccessResourceType(user.role, resource.type)) {
return false; // cut without loading object and traversing ABAC-policies
}
// Fine check via ABAC
return engine.evaluate(user, resource, action);
}
Timeline and Complexity
Basic engine with policies in code—3–4 days. Engine with database policies and UI for editing them—7–10 days. Adding audit log with UI—another 2–3 days. Integration with third-party PDP (Open Policy Agent, Casbin) instead of custom engine—2–3 days for integration plus policy writing time.
Open Policy Agent is a mature alternative to a custom engine. Policies are written in Rego, OPA runs as a sidecar or separate service, your application calls it via HTTP or gRPC. This adds operational complexity but provides policy versioning, hot reload, and built-in audit.







