Авторизация: RBAC, ABAC, ACL, multi-tenancy и проверка прав в коде
Аутентификация отвечает на вопрос «кто передо мной» — этому посвящена предыдущая статья. Авторизация отвечает на другой вопрос: «что именно этому субъекту разрешено сделать с этим ресурсом прямо сейчас». Разница принципиальная: безупречный вход с passkey и аппаратным ключом не значит ничего, если после входа пользователь может подставить чужой идентификатор в URL и получить чужой счёт.
Поэтому в OWASP Top 10 редакции 2021 категория A01 Broken Access Control стоит на первом месте: она нашлась в 94% протестированных приложений, и по ней зафиксировано больше всего CVE — свыше 318 тысяч случаев. Это не экзотика и не тонкая криптографическая ошибка, а забытая строчка WHERE tenant_id = ... и хендлер, который доверяет идентификатору из запроса. Статья написана с позиции защищающейся стороны. Уязвимые фрагменты приведены, чтобы вы узнали их в собственном репозитории и починили, а не для применения к чужим системам. Любая практическая проверка — только на системах, которыми вы владеете, либо при письменном разрешении владельца с зафиксированным объёмом работ и окном проведения. Базовые CWE: 284 (Improper Access Control), 285 (Improper Authorization), 862 (Missing Authorization), 863 (Incorrect Authorization), 639 (Authorization Bypass Through User-Controlled Key).
Матрица доступа: базовая абстракция
Все модели контроля доступа — производные от одной идеи, сформулированной Батлером Лэмпсоном в 1971 году (Protection, ACM SIGOPS OSR, 1974). Есть множество субъектов, множество объектов и матрица, где в ячейке записано, какие операции субъект может выполнять над объектом. Матрицу режут двумя способами, и оба живут в реальных системах.
- По столбцу — ACL (access control list). Список «кому что можно» хранится рядом с объектом: права файлов в POSIX, ACL в S3-совместимых хранилищах. Сильная сторона — мгновенный ответ на вопрос аудита «кто имеет доступ к этому документу».
- По строке — capability list. Права хранятся рядом с субъектом: роли в токене,
scopeв OAuth-доступе (см. OAuth 2.0 и OpenID Connect), подписанная ссылка на файл. Быстрый ответ на «что может субъект», но обратный вопрос требует полного перебора.
Полную матрицу не хранит никто: 50 тысяч пользователей на 10 миллионов объектов — это 500 миллиардов ячеек. Все практические модели суть способы сжатия матрицы: RBAC вставляет промежуточный слой ролей и меняет размер S·O на S·R + R·P, ABAC не хранит матрицу вовсе, а вычисляет ячейку по правилу из атрибутов, ReBAC хранит рёбра графа, и ячейка — это достижимость. Сама же проверка — функция от четырёх входов, и у каждого есть то, чему доверять нельзя:
| Вход | Что это | Чему нельзя доверять |
|---|---|---|
| Субъект | кто действует | ролям и tenant_id, присланным клиентом |
| Действие | что делает | заголовкам вроде X-Original-Method |
| Ресурс | над чем | принадлежности ресурса без проверки |
| Контекст | время, IP, уровень MFA | значениям из тела запроса |
Результат — не только «да/нет», но и обязательства (obligations): «разрешено, но замаскируй номер карты», «разрешено, но запиши в аудит», «разрешено 24 часа». Главное правило сформулировали Зальцер и Шрёдер в 1975 году в работе The Protection of Information in Computer Systems: fail-safe defaults — по умолчанию запрещено, разрешение выдаётся явно (то же требует NIST SP 800-53, контроль AC-3). Система, где новый эндпоинт по умолчанию доступен всем, а безопасность добавляется отдельным декоратором, обречена: забыть проще, чем не забыть. Рядом стоят least privilege (минимум прав на минимум времени) и complete mediation (проверка на каждом обращении). Кэш решения на пять минут — осознанный компромисс с complete mediation, и принимать его надо сознательно, а не случайно.
Таксономия моделей
Модели не конкурируют, а слоятся: в типичном продакшне одновременно работают RBAC для функций админки, ReBAC для расшаривания документов, ABAC для контекстных ограничений («только из корпоративной сети») и capability для временных ссылок на файлы.
RBAC: ролевая модель без иллюзий
Стандарт ANSI/INCITS 359-2004 описывает четыре уровня: RBAC0 — субъекты, роли, разрешения, сессии; RBAC1 — иерархия ролей (admin включает editor, тот включает viewer); RBAC2 — разделение обязанностей (SoD), статическое «нельзя иметь обе роли» и динамическое «нельзя активировать обе в одной сессии», классика — «создающий платёж не утверждает его»; RBAC3 — объединение первых двух.
Четыре решения в этой схеме несут основную нагрузку. Роль принадлежит арендатору — глобальная таблица ролей кончается тем, что администратор одного клиента переименовывает роль всем. Разрешение — строка ресурс:действие (invoice:refund), а не число и не элемент enum: такой формат переживает рефакторинг лучше битовой маски. MEMBERSHIP вместо прямой связи user→tenant: один человек работает в трёх компаниях, а членство — это то, что истекает, отзывается и аудируется. GRANT — точечный доступ на конкретный ресурс; без него любое «дай Пете посмотреть вот этот отчёт» рождает новую роль.
Отсюда главное практическое правило — проверять разрешение, а не роль:
# УЯЗВИМО: проверка на имя роли, принадлежность ресурса не проверяется
def refund_invoice(user, invoice_id: str):
if user.role != "admin":
raise Forbidden()
db.invoices.get(invoice_id).refund()
Почему это ломается. user.role — одна строка, а ролей у человека бывает несколько; появление роли finance_manager, которой тоже нужны возвраты, требует правки во всех местах с этим if — обычно их десятки, и одно точно забудут. Сравнение по значению делает опечатку "Admin" тихим отказом вместо ошибки. И главное: принадлежность invoice арендатору пользователя не проверяется вообще — администратор компании A возвращает счёт компании B. Как чинить — разделить «что можно делать» и «над чем»:
@dataclass(frozen=True)
class Principal: # то, что известно о субъекте на время запроса
user_id: str
tenant_id: str
permissions: frozenset[str] # развёрнутые из ролей, не сами роли
mfa_level: int
def require(principal: Principal, permission: str) -> None:
if permission not in principal.permissions:
raise Forbidden(permission)
def refund_invoice(principal: Principal, invoice_id: str, repo, audit):
require(principal, "invoice:refund") # 1. право на действие
# 2. ресурс достаётся ТОЛЬКО в области видимости субъекта:
# поиск и проверка принадлежности — один запрос, а не два шага
invoice = repo.get_scoped(invoice_id, tenant_id=principal.tenant_id)
if invoice is None:
raise NotFound() # 404, а не 403: не подтверждаем существование
# 3. контекстное ограничение поверх разрешения
if invoice.amount_cents > 100_000_00 and principal.mfa_level < 2:
raise StepUpRequired()
invoice.refund()
audit.write("invoice.refund", principal, invoice.id, decision="allow")
Три отличия, каждое существенно: проверяется разрешение, а роли остаются деталью конфигурации, и новая роль не требует правок кода; ресурс невозможно получить вне области видимости, потому что метод не принимает запрос без tenant_id; отказ по чужому или несуществующему ресурсу даёт 404, а не 403 — иначе разница в ответах превращает эндпоинт в оракул существования объектов. Плоский набор разрешений считается один раз на запрос и кладётся в Principal:
-- Иерархия ролей раскрывается рекурсивным CTE; глубина в реальных
-- системах 3–5 уровней, поэтому стоимость O(рёбер) пренебрежимо мала.
WITH RECURSIVE effective_roles(role_id) AS (
SELECT mr.role_id FROM membership_role mr
JOIN membership m ON m.id = mr.membership_id
WHERE m.user_id = $1 AND m.tenant_id = $2
AND (m.expires_at IS NULL OR m.expires_at > now())
UNION
SELECT rh.child_role_id FROM role_hierarchy rh
JOIN effective_roles er ON er.role_id = rh.parent_role_id
)
SELECT DISTINCT rp.permission_key FROM effective_roles er
JOIN role_permission rp ON rp.role_id = er.role_id;
Класть этот список в JWT — отдельное решение с последствиями: набор разрешений застывает до истечения токена, и отзыв роли не действует немедленно. Компромисс разобран в статье JWT и токены; коротко — в токен разумно класть идентичность и максимум грубый признак (tenant_id, is_service), а точные разрешения читать на стороне сервиса.
Взрыв ролей. Роль — не должность: как только роли называются manager_moscow_readonly_q3, модель сломана, потому что их число растёт как произведение измерений.
| Симптом | Причина | Что делать |
|---|---|---|
| Ролей больше, чем разрешений | роль на каждую комбинацию | вынести измерения (регион, проект) в атрибуты или scope |
| Роль на одного человека | нет точечных grant | добавить GRANT на ресурс |
| Роли зашиты во фронтенд | UI решает, что показать | UI скрывает, сервер решает |
ABAC: когда ролей не хватает
NIST SP 800-162 описывает ABAC как решение по правилам над атрибутами субъекта, ресурса, действия и среды. Пример, невыразимый в RBAC без изобретения десятка ролей: «врач видит карту пациента, если он лечащий врач, или дежурит в этом отделении сейчас, или объявлен режим неотложной помощи — и последнее пишется в аудит».
package app.authz
default allow := false # fail-safe default
allow if { # лечащий врач своего пациента
input.action == "record:read"
input.subject.role == "physician"
input.resource.attending_physician_id == input.subject.id
}
allow if { # дежурство: отделение и текущая смена
input.action == "record:read"
input.subject.on_shift_department == input.resource.department
time.now_ns() < input.subject.shift_ends_at_ns
}
allow if { # break-glass: разрешаем с обязательством
input.context.emergency_declared
input.subject.role in {"physician", "nurse"}
}
obligations contains "audit:break_glass" if { input.context.emergency_declared }
| Критерий | RBAC | ABAC | ReBAC |
|---|---|---|---|
| Выразительность контекста | низкая | высокая | средняя |
| «Кто имеет доступ к X?» | запрос по таблицам | обратная задача, дорого | штатная операция (expand) |
| «Что доступно субъекту?» | тривиально | перебор ресурсов | штатная операция (list-objects) |
| Стоимость внедрения | низкая | высокая | высокая |
| Где уместна | функции админки | регуляторные ограничения | расшаривание, папки, команды |
Главная ловушка ABAC — обратные вопросы. Аудитор спрашивает «кто имеет доступ к карте пациента Иванова», а политика — это программа; чтобы ответить, придётся перебирать всех субъектов либо строить отдельный индекс. Это не повод отказываться от ABAC, но повод заранее спроектировать ответ на такие вопросы. Движки: Open Policy Agent с языком Rego и AWS Cedar, у которого политики специально ограничены ради разрешимого анализа.
ReBAC и подход Zanzibar
Google Zanzibar (USENIX ATC 2019) — система авторизации для Drive, YouTube и Cloud. Её модель: доступ есть достижимость в графе отношений, а данные — кортежи объект#отношение@субъект:
document:budget2026#viewer@user:anna
document:budget2026#parent@folder:finance
folder:finance#viewer@group:accounting#member
group:accounting#member@user:boris
Конфигурация пространства имён объявляет, что viewer документа — это прямые viewer плюс viewer родительской папки. Борис получает доступ транзитивно, без единой записи о нём в документе. Реализации с открытым кодом — OpenFGA (CNCF) и SpiceDB. Что нужно понимать до внедрения. Согласованность: права отозвали, а реплика ещё отдаёт старое — «проблема нового врага» (new enemy problem); Zanzibar решает её токенами zookie, привязывающими проверку к версии данных, теоретическая база — в статье Модели согласованности. Стоимость: отдельный сервис авторизации — сетевой вызов на каждой проверке, нужны батчинг, кэш и бюджет задержки. Уместность: продукту про расшаривание с наследованием (документы, репозитории, команды) ReBAC нужен; админке из тридцати эндпоинтов — нет.
Где в системе стоит проверка
Термины XACML удобны как общий язык: PEP — место, где решение применяется (код, бросающий Forbidden); PDP — то, что решение принимает (библиотека, OPA, движок ReBAC); PIP — источник атрибутов; PAP — где политики пишут и версионируют.
UI прячет кнопки — это UX, а не защита"] --> E["Edge / WAF
грубая фильтрация по пути"] E --> G["API Gateway
аутентификация, scope, rate limit"] G --> H["HTTP-хендлер
PEP #1: право на действие"] H --> UC["Use case / сервис
PEP #2: право на конкретный ресурс"] UC --> DOM["Доменные инварианты
кто может менять состояние"] DOM --> REPO["Репозиторий со скоупом
запрос всегда с tenant_id"] REPO --> DB[("БД + RLS
последний рубеж")] UC -.->|"subject, action, resource, context"| PDP["PDP: движок политик"] PDP -.->|"allow / deny + obligations"| UC PIP["PIP: атрибуты
членства, гриф, смена"] -.-> PDP style DB fill:#c99a4a,fill-opacity:0.25,stroke:#8a6a2a style UC fill:#5f9f7f,fill-opacity:0.25,stroke:#3f7f5f
Три правила размещения. Право на действие проверяется как можно раньше, чтобы не грузить лишнее, а право на ресурс — там, где ресурс уже известен, то есть в слое сценария, а не в middleware. Шлюз не заменяет сервис: фильтрация по URL на периметре не знает, чей это ресурс, а разбор пути на прокси и во фреймворке различается — на этом строится целый класс обходов (path confusion), см. Безопасность API. Клиент не решает ничего: спрятанная кнопка — вежливость к пользователю, эндпоинт обязан отвечать 403 независимо от того, показывали её или нет.
IDOR/BOLA: разбор самой частой дыры
Insecure Direct Object Reference (в терминах OWASP API Security Top 10 — BOLA, API1:2023) — доступ к чужому объекту простой подстановкой идентификатора.
Как выглядит уязвимый код.
@app.get("/api/invoices/{invoice_id}") # УЯЗВИМО: id из запроса идёт прямо в предикат
def get_invoice(invoice_id: str, user = Depends(current_user)):
return db.query(Invoice).filter(Invoice.id == invoice_id).one()
Почему это работает. Идентификатор приходит от клиента и напрямую попадает в предикат выборки; проверено только то, что запрос сделал какой-то аутентифицированный пользователь. Отношение между пользователем и объектом не проверяется вообще (CWE-639). Замена последовательных ID на UUID снижает удобство перебора, но не защищает: идентификаторы утекают в логи, письма, реферер, экспорт, вебхуки и в ответы соседних эндпоинтов. Обфускация — не контроль доступа. Как чинить правильно: сделать обращение к репозиторию без области видимости невозможным на уровне API, а не дисциплины.
class ScopedInvoiceRepo:
"""Репозиторий, который нельзя сконструировать без арендатора."""
def __init__(self, session, tenant_id: str):
if not tenant_id:
raise ValueError("репозиторий требует tenant_id")
self._s, self._tenant_id = session, tenant_id
def get(self, invoice_id: str) -> Invoice | None:
return (self._s.query(Invoice)
.filter(Invoice.id == invoice_id,
Invoice.tenant_id == self._tenant_id) # всегда
.one_or_none())
@app.get("/api/invoices/{invoice_id}") # хендлер физически не видит сырой репозиторий
def get_invoice(invoice_id: str, principal = Depends(current_principal)):
require(principal, "invoice:read")
invoice = ScopedInvoiceRepo(session, principal.tenant_id).get(invoice_id)
if invoice is None:
raise HTTPException(404)
return serialize(invoice)
Тот же приём на уровне типов — «авторизованное» значение нельзя получить в обход проверки:
// Брендированный тип: значение существует только как результат authorize()
type Authorized<T> = T & { readonly __authorized: unique symbol };
function authorize<T extends { tenantId: string }>(
principal: Principal, permission: string, entity: T | null,
): Authorized<T> {
if (!principal.permissions.has(permission)) throw new Forbidden(permission);
if (!entity || entity.tenantId !== principal.tenantId) throw new NotFound();
return entity as Authorized<T>;
}
// Домен принимает только Authorized<Invoice> — забыть проверку не даст компилятор
function refund(invoice: Authorized<Invoice>, amount: Money): RefundResult { /* ... */ }
Как проверить. Тест «два арендатора»: пользователь A запрашивает объект B — ожидается 404, не 200 и не 500. Тест на все методы объекта: GET, PATCH, DELETE, вложенные пути /invoices/{id}/attachments/{aid}, экспорт, вебхуки — BOLA чаще всего забывают на второстепенных методах. Архитектурный тест-линтер: слой handlers не имеет права импортировать репозиторий без скоупа. И проверка списочных эндпоинтов: GET /invoices не отдаёт чужие записи ни при каких значениях фильтров, сортировок и include.
Массовое присвоение: авторизация на уровне поля
Право читать объект и право менять конкретное поле — разные права (CWE-915).
# УЯЗВИМО: массовое присвоение — тело запроса раскладывается в модель целиком
@app.patch("/api/users/{user_id}")
def update_user(user_id: str, payload: dict, principal = Depends(current_principal)):
user = repo.get(user_id)
for k, v in payload.items():
setattr(user, k, v) # role, tenant_id, is_verified, balance...
repo.save(user)
Клиент передаёт {"name": "Аня", "role": "owner"} и повышает себе права. Правильно — строгая схема, allowlist полей и отдельное разрешение на каждое чувствительное поле:
SELF_EDITABLE = {"name", "avatar_url", "locale"}
PRIVILEGED = {"role": "member:set_role", "is_verified": "user:verify"}
@app.patch("/api/users/{user_id}")
def update_user(user_id: str, payload: UserPatch, principal = Depends(current_principal)):
data = payload.model_dump(exclude_unset=True) # типизированная схема, не dict
unknown = set(data) - SELF_EDITABLE - set(PRIVILEGED)
if unknown:
raise HTTPException(400, f"нередактируемые поля: {sorted(unknown)}")
for field, permission in PRIVILEGED.items():
if field in data:
require(principal, permission) # своё право на каждое поле
user = ScopedUserRepo(session, principal.tenant_id).get(user_id)
if user is None:
raise HTTPException(404)
if user.id != principal.user_id:
require(principal, "user:update_other")
apply_patch(user, data)
Отдельно: никогда не принимайте tenant_id из тела или заголовка — арендатор определяется сессией или токеном, а tenant_id во входных данных это готовая уязвимость смены арендатора.
Multi-tenancy: изоляция арендаторов
Абсолютное большинство SaaS работает по модели 1 — общая схема с колонкой tenant_id. Она дешевле всех, и цена ошибки в ней максимальна, поэтому её закрывают тремя независимыми слоями. Слой 1 — скоуп в коде. Область видимости не должна быть опцией: репозитории, принимающие tenant_id в конструкторе (см. выше); глобальный фильтр ORM (Query Filters в EF Core, default_scope в ORM Ruby, @Filter в Hibernate); запрет сырых запросов вне выделенного модуля.
Слой 2 — Row-Level Security в PostgreSQL. Страховка на случай, когда предикат всё-таки забыли (документация):
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- политика действует и на владельца
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- USING — что видно при SELECT/UPDATE/DELETE
-- WITH CHECK — что разрешено записать при INSERT/UPDATE
CREATE ROLE app_runtime LOGIN NOBYPASSRLS; -- без SUPERUSER и BYPASSRLS
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_runtime;
Контекст ставится на транзакцию — при пуле соединений это критично:
@contextmanager
def tenant_scope(session, tenant_id: str):
# SET LOCAL живёт до конца транзакции и не протекает к следующему
# клиенту, взявшему это соединение из пула; with .begin() даёт
# commit при успехе и rollback при исключении
with session.begin():
session.execute(text("SET LOCAL app.tenant_id = :t"), {"t": tenant_id})
yield session
| Грабли RLS | Что происходит | Как избежать |
|---|---|---|
SET вместо SET LOCAL |
значение остаётся в соединении и достаётся следующему запросу | только SET LOCAL внутри транзакции |
| Приложение под владельцем таблицы | владелец обходит политики | отдельная роль плюс FORCE ROW LEVEL SECURITY |
| Новая таблица без политики | миграция добавила таблицу, RLS забыли | тест по pg_class: нет таблиц с tenant_id и relrowsecurity = false |
| Внешний пул в режиме сессии | SET LOCAL и запрос разъезжаются |
транзакционный режим пула и проверка его в тестах |
Функции SECURITY DEFINER |
выполняются с правами создателя, минуя логику вызывающего | явно проверять аргументы внутри функции |
Слой 3 — кросс-арендаторные тесты. Они ловят то, чего не увидит ни один статический анализатор:
ENDPOINTS = [("GET", "/api/invoices/{id}"), ("PATCH", "/api/invoices/{id}"),
("DELETE", "/api/invoices/{id}"), ("GET", "/api/invoices/{id}/attachments"),
("POST", "/api/invoices/{id}/refund")] # включая второстепенные!
@pytest.mark.parametrize("method,path", ENDPOINTS)
def test_cross_tenant_is_not_found(client, method, path, tenant_a, tenant_b):
"""Ресурс арендатора B невидим для владельца арендатора A."""
invoice = create_invoice(tenant_b)
resp = client.request(method, path.format(id=invoice.id),
headers=auth_as(tenant_a.owner))
assert resp.status_code == 404, \
f"{method} {path} вернул {resp.status_code}: возможен обход изоляции"
Рядом полезны матрица «роль × эндпоинт × ожидаемый статус» и тест-«сторож», перебирающий все зарегистрированные маршруты приложения и падающий, если у маршрута нет ни одной декларации прав. Новый эндпоинт без политики не должен попадать в main — это дешевле любого пентеста. Про место таких проверок в конвейере — Безопасная разработка и Тестирование безопасности.
Иерархии, наследование и отзыв
Реальные системы почти всегда имеют дерево: организация → рабочее пространство → проект → ресурс, и отсюда два вопроса. Как считать эффективное разрешение. Идём от ресурса вверх, собирая grant’ы, и останавливаемся на явном deny или на корне. Стоимость O(h) на проверку, где h — глубина (обычно 3–5). Для списочных операций так нельзя: получится O(n·h) запросов; списки фильтруются одним запросом с подзапросом по доступным идентификаторам или материализованной таблицей effective_access.
Как разрешать конфликты. Выбрать один подход и записать его в документацию. deny-overrides — любой запрет побеждает: предсказуемо и безопасно, но запрет на уровне организации нельзя точечно ослабить. permit-overrides — более специфичное правило побеждает общее: гибко, но чтобы понять, почему доступ есть, надо обойти всё дерево.
Жизненный цикл членства забывают чаще всего: роль отозвали в админке, а пользователь продолжает работать, потому что его Principal закэширован, а токен живёт ещё пятнадцать минут. Отзыв обязан быть событием, а не строкой в таблице, и включать сброс сессий, инвалидацию кэша прав, отзыв токенов и запись в аудит.
Производительность: где авторизация становится узким местом
Четыре приёма закрывают почти все проблемы производительности. Батчинг: экран со списком из пятидесяти карточек не должен делать пятьдесят проверок. Фильтрация вместо постфильтрации: антипаттерн «достать 10 000 записей и отфильтровать в приложении» ломает пагинацию (страница получается неполной), нагружает БД и всё равно течёт через счётчики и агрегаты — правильно ставить предикат в SQL или использовать listAccessible. Кэш решений с версией политики: ключ включает версию политики и версию членств, изменение любой инвалидирует всё разом, а TTL измеряется секундами, иначе отзыв прав становится неопределённо долгим. Разделение горячего и холодного: право на действие (invoice:refund) меняется редко и живёт в Principal на время запроса, право на ресурс проверяется всегда. Компромисс «свежесть против задержки» здесь тот же, что в кэшировании данных — общая рамка в статье Кэширование и масштабирование.
Аудит: что писать в лог
Аудит контроля доступа — не то же самое, что логи приложения: требования NIST SP 800-53 (семейство AU) сводятся к тому, что по записи должно восстанавливаться, кто, что, над чем, когда, откуда и с каким результатом.
{
"ts": "2026-07-16T10:04:11.512Z", "event": "authz.decision", "decision": "deny",
"reason": "missing_permission:invoice:refund", "request_id": "r_77c2",
"subject": {"user_id": "u_8a3", "tenant_id": "t_acme", "session_id": "s_91f"},
"action": "invoice:refund",
"resource": {"type": "invoice", "id": "inv_9F31", "tenant_id": "t_acme"},
"context": {"ip": "203.0.113.10", "mfa_level": 1, "policy_version": "2026-07-14"}
}
Логировать нужно все deny и чувствительные allow — возвраты, экспорт данных, смену ролей, доступ администратора к данным клиента. Не писать в аудит токены, пароли и содержимое персональных данных, только идентификаторы (тема пересекается с Приватностью и соответствием). Обязательно фиксировать policy_version, иначе через полгода не восстановить, по какому правилу принято решение. Всплеск deny по одному субъекту — повод для алерта: так выглядит и ошибка релиза, и перебор идентификаторов; как встроить это в наблюдаемость — см. Наблюдаемость. Отдельная тема — impersonation, вход администратора «под пользователем». Это легитимная функция поддержки и одновременно самый удобный чёрный ход. Минимум: явное согласие или тикет, ограничение по времени, отдельный признак в токене (act по RFC 8693), запрет опасных действий в этом режиме, отдельная лента аудита и уведомление пользователя.
Типичные ошибки
- Проверка только в middleware по маршруту. Middleware не знает, чей объект: право на действие — да, право на ресурс — нет.
- Проверка на роль в бизнес-коде. Новая роль требует правок в десятках мест, одно забудут.
- Доверие данным из запроса.
tenant_id,owner_id,role,priceиз тела — входные данные, а не факты. - Разные ответы для «нет объекта» и «нет прав». Разница в статусе, тексте или времени ответа делает эндпоинт оракулом, а непредсказуемый UUID сам по себе правом доступа не является.
- Забытые второстепенные методы.
GETзащитили;DELETE, экспорт, вебхук и GraphQL-резолвер — нет. - Постфильтрация списков в приложении. Ломает пагинацию и течёт через агрегаты.
- Приложение ходит в БД под суперпользователем — обесценивает RLS, а кэш прав без инвалидации делает отзыв роли неопределённо долгим.
- Массовые операции без проверки каждого элемента.
POST /invoices/bulk-deleteс массивом ID — классический обход, если проверяется только первый. - Отсутствие негативных тестов. Проверяют, что «менеджеру можно», и не проверяют, что «постороннему нельзя»; второе важнее.
Итог
Авторизация — не декоратор над хендлером, а сквозное архитектурное свойство. Программа-минимум.
- Deny by default. Эндпоинт без объявленной политики не должен проходить тесты.
- В коде — разрешения, а не роли. Роли это конфигурация, разрешения — контракт.
- Ресурс достаётся только со скоупом. Тип или репозиторий, непригодные к использованию без
tenant_idи владельца, устраняют целый класс IDOR. - Многослойность. Код плюс БД (RLS) плюс тесты; ни один слой не отменяет остальные.
- Модель по задаче. RBAC для функций, ABAC для контекста, ReBAC для расшаривания: не начинать со сложного, но заранее знать, куда расти.
- Отзыв — событие (сессии, кэш, токены), а аудит решений — с версией политики и алертом на всплеск отказов.
И последнее: любые проверки описанных механизмов проводятся только на своей инфраструктуре или по письменному разрешению владельца с согласованным объёмом работ.
Источники
- OWASP Top 10 2021, A01 Broken Access Control; Authorization Cheat Sheet; Authorization Testing Automation Cheat Sheet; API Security Top 10 2023: API1 BOLA и API3 Broken Object Property Level Authorization
- NIST SP 800-162: Guide to Attribute Based Access Control; NIST SP 800-53 Rev. 5, семейства AC и AU
- ANSI/INCITS 359-2004, Role Based Access Control; Sandhu et al., Role-Based Access Control Models, IEEE Computer, 1996
- Lampson B. Protection, ACM SIGOPS OSR, 1974; Saltzer J., Schroeder M. The Protection of Information in Computer Systems, Proceedings of the IEEE, 1975
- Zanzibar: Google’s Consistent, Global Authorization System, USENIX ATC 2019; реализации — OpenFGA и SpiceDB; OASIS XACML 3.0; Open Policy Agent; AWS Cedar; PostgreSQL: Row Security Policies; RFC 8693 — OAuth 2.0 Token Exchange про
actи делегирование - CWE: 284, 285, 639, 732, 862, 863, 915, 1220 — контрольный список для ревью
Что дальше
Мы разобрали, кто и что имеет право делать. Дальше — инструменты, на которых держатся сами механизмы доверия: чем симметричное шифрование отличается от асимметричного, зачем нужны подписи, почему хеш и KDF это разные вещи и как не изобрести собственный протокол.
Прикладная криптография: симметрика, асимметрика, хеши, подписи, KDF