We often run into situations where RBAC is bursting at the seams. On one project, after RBAC implementation we received 47 role change requests per month—each requiring approval. ABAC cut that to 2 requests. Rules emerge like "a user can edit a document if they are its author, the document is in draft status, and the user works in the same organization as the document." A role alone can't handle that—context is needed. ABAC (Attribute-Based Access Control) makes decisions based on attributes of the subject (user), object (resource), and environment (time, IP, request context). Our experience shows that a hybrid RBAC+ABAC approach provides the optimal balance of performance and flexibility. We guarantee decision transparency with detailed audit.
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.
A 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'
Why ABAC Is Better Than RBAC?
RBAC is simple and fast, but does not scale for complex rules. ABAC can express almost any business constraint: "a manager can approve a request if the amount < 10000 and the request was created in their department." In RBAC, you would need to introduce a new role. ABAC handles this declaratively. Let's compare key parameters:
| Criteria | RBAC | ABAC |
|---|---|---|
| Flexibility | Low (fixed roles) | High (attributes) |
| Context support | No | Yes |
| Performance | High | Medium (with many policies) |
| Implementation complexity | Low | Medium |
Policy Storage Schema
Policies can be stored in code (suitable for a small number of rules) or in a database using a DSL. Here's a variant with PostgreSQL storing conditions as JSON:
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 entry
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)))
);
}
}
How to Integrate ABAC in 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 engine 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 guesswork.
Combining with RBAC
Pure ABAC is slower than RBAC under many policies—each check goes through all rules. In practice, a hybrid is used: RBAC as the first layer (fast coarse check by role), ABAC as the second (fine-grained contextual rules only 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 off without loading object and traversing ABAC policies
}
// Fine-grained check via ABAC
return engine.evaluate(user, resource, action);
}
How to Assess Project Complexity?
Timelines depend on the number of policies and architecture. We distinguish three options:
| Option | Timeline | What's included |
|---|---|---|
| Basic | 3–4 days | Engine in code, 5–10 policies, tests |
| Advanced | 7–10 days | Policies in DB, REST API, UI for editing, audit |
| Integration with OPA | 2–3 days | Sidecar deployment, writing Rego policies, testing |
What's Included in ABAC Implementation?
- Analysis of access model and attributes of users, resources, environment.
- Design and documentation of access policies.
- Development of decision engine (or integration with OPA/Casbin).
- Implementation of REST API for policy management.
- Creation of audit system with log visualization.
- Test coverage (unit + integration).
- Deployment and monitoring setup.
- Team training: how to write and debug policies.
- Code warranty and post-deployment support.
Basic engine with policies stored in code — 3–4 days. Engine with policies in DB and UI for editing — 7–10 days. Adding audit log with UI — another 2–3 days. Integration with a third-party PDP (Open Policy Agent, Casbin) instead of a custom engine — 2–3 days for integration plus time to write policies in Rego or PERM.
Open Policy Agent is a mature alternative to a custom engine. Policies are written in Rego, OPA runs as a sidecar or separate service, and the application communicates via HTTP or gRPC. This adds operational complexity, but provides policy versioning, hot reload, and built-in audit.
If you have complex access requirements, contact us for an audit. We'll assess your project for free. Get in touch to discuss details. Order a turnkey ABAC implementation.







