Впровадження ABAC (Attribute-Based Access Control) для веб-додатків
RBAC розтріскується, коли з'являються правила на кшталт «користувач може редагувати документ, якщо він його автор, документ у статусі draft, і користувач працює в тій же організації, що й документ». Роль тут не допоможе — потрібен контекст. ABAC приймає рішення на основі атрибутів суб'єкта (користувача), об'єкта (ресурсу) та середовища (час, IP, контекст запиту).
Як влаштована модель
Чотири сутності в ABAC:
Subject — користувач та його атрибути: role, department, clearance_level, org_id.
Resource — об'єкт та його атрибути: owner_id, status, org_id, classification, region.
Action — read, write, delete, approve.
Environment — time_of_day, ip_address, request_method.
Політика — це предикат над цими атрибутами. Наприклад:
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'
Схема зберігання політик
Можна зберігати політики в коді (для невеликої кількості правил) або в базі з DSL. Ось варіант зберігання у PostgreSQL як 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, -- дерево умов
actions TEXT[] NOT NULL,
resources TEXT[] NOT NULL -- glob: 'documents', 'documents/*'
);
-- Приклад запису
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/*']
);
Машина прийняття рішень
class ABACEngine {
constructor(policies) {
// Політики попередньо завантажені та відсортовані за пріоритетом (deny > allow при конфлікті)
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; // за замовчуванням 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);
}
// Листовий вузол
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)))
);
}
}
Інтеграція з Express
const engine = new ABACEngine(await loadPoliciesFromDB());
// Перезавантажуємо політики при зміні (без рестарту сервера)
db.on('policy_changed', async () => {
engine.updatePolicies(await loadPoliciesFromDB());
});
function abac(action) {
return async (req, res, next) => {
const resource = await loadResource(req); // завантажуємо об'єкт з усіма атрибутами
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);
Аудит-лог
ABAC без аудиту — сліпий інструмент. Кожне рішення логується:
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 -- снімок атрибутів subject+resource у момент рішення
);
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);
Це дає відповідь на питання «чому користувач X не мав змоги зробити Y з об'єктом Z три дні тому» — без нього розслідування інцидентів перетворюється на гадання.
Комбінування з RBAC
Чистий ABAC повільніший за RBAC при великій кількості політик — кожна перевірка проходить через усі правила. На практиці використовують гібрид: RBAC як перший шар (швидка грубої перевірки за роллю), ABAC як другий (тонкі контекстуальні правила тільки там, де потрібно).
async function authorize(user, resource, action) {
// Швидка RBAC-перевірка: чи у ролі є хоч якийсь доступ до цього типу ресурсу?
if (!await rbac.canAccessResourceType(user.role, resource.type)) {
return false; // відсікаємо без завантаження об'єкта та проходу по ABAC-політикам
}
// Тонка перевірка через ABAC
return engine.evaluate(user, resource, action);
}
Терміни та складність
Базовий движок з політиками в коді — 3–4 дні. Движок з політиками в базі та UI для їх редагування — 7–10 днів. Додавання аудит-логу з UI — ще 2–3 дні. Інтеграція зі сторонньою PDP (Open Policy Agent, Casbin) замість самописного движка — 2–3 дні на інтеграцію плюс час на написання політик.
Open Policy Agent — зріла альтернатива самописному движку. Політики пишуться на Rego, OPA запускається як sidecar або як окремий сервіс, ваш додаток звертається до нього через HTTP або gRPC. Це додає операційну складність, але дає версіонування політик, гарячу перезагрузку та вбудований аудит.







