Реализация 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; // 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);
}
// Листовой узел
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);
Audit log
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 -- snapshot subject+resource attrs на момент решения
);
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-check: есть ли у роли хоть какой-то доступ к этому типу ресурса?
if (!await rbac.canAccessResourceType(user.role, resource.type)) {
return false; // отсекаем без загрузки объекта и прохода по ABAC-политикам
}
// Тонкая проверка через ABAC
return engine.evaluate(user, resource, action);
}
Сроки и сложность
Базовый движок с хранением политик в коде — 3–4 дня. Движок с хранением политик в базе и UI для их редактирования — 7–10 дней. Добавление audit log с UI — ещё 2–3 дня. Интеграция со сторонней PDP (Open Policy Agent, Casbin) вместо самописного движка — 2–3 дня на интеграцию плюс время на написание политик на Rego или PERM.
Open Policy Agent — зрелая альтернатива самописному движку. Политики пишутся на Rego, OPA запускается как sidecar или как отдельный сервис, приложение обращается к нему через HTTP или gRPC. Это добавляет операционную сложность, но даёт версионирование политик, горячую перезагрузку и встроенный аудит.







