Безопасность приложений Авторизация: RBAC, ABAC, ACL, multi-tenancy и проверка прав в коде
0%

Авторизация: RBAC, ABAC, ACL, multi-tenancy и проверка прав в коде

Авторизация: 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 по столбцу, capability по строке

  • По столбцу — 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 — где политики пишут и версионируют.

Три правила размещения. Право на действие проверяется как можно раньше, чтобы не грузить лишнее, а право на ресурс — там, где ресурс уже известен, то есть в слое сценария, а не в 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: изоляция арендаторов

Три модели изоляции арендаторов: общая схема с tenant_id, схема на арендатора, база на арендатора

Абсолютное большинство 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), запрет опасных действий в этом режиме, отдельная лента аудита и уведомление пользователя.

Типичные ошибки

  1. Проверка только в middleware по маршруту. Middleware не знает, чей объект: право на действие — да, право на ресурс — нет.
  2. Проверка на роль в бизнес-коде. Новая роль требует правок в десятках мест, одно забудут.
  3. Доверие данным из запроса. tenant_id, owner_id, role, price из тела — входные данные, а не факты.
  4. Разные ответы для «нет объекта» и «нет прав». Разница в статусе, тексте или времени ответа делает эндпоинт оракулом, а непредсказуемый UUID сам по себе правом доступа не является.
  5. Забытые второстепенные методы. GET защитили; DELETE, экспорт, вебхук и GraphQL-резолвер — нет.
  6. Постфильтрация списков в приложении. Ломает пагинацию и течёт через агрегаты.
  7. Приложение ходит в БД под суперпользователем — обесценивает RLS, а кэш прав без инвалидации делает отзыв роли неопределённо долгим.
  8. Массовые операции без проверки каждого элемента. POST /invoices/bulk-delete с массивом ID — классический обход, если проверяется только первый.
  9. Отсутствие негативных тестов. Проверяют, что «менеджеру можно», и не проверяют, что «постороннему нельзя»; второе важнее.

Итог

Авторизация — не декоратор над хендлером, а сквозное архитектурное свойство. Программа-минимум.

  1. Deny by default. Эндпоинт без объявленной политики не должен проходить тесты.
  2. В коде — разрешения, а не роли. Роли это конфигурация, разрешения — контракт.
  3. Ресурс достаётся только со скоупом. Тип или репозиторий, непригодные к использованию без tenant_id и владельца, устраняют целый класс IDOR.
  4. Многослойность. Код плюс БД (RLS) плюс тесты; ни один слой не отменяет остальные.
  5. Модель по задаче. RBAC для функций, ABAC для контекста, ReBAC для расшаривания: не начинать со сложного, но заранее знать, куда расти.
  6. Отзыв — событие (сессии, кэш, токены), а аудит решений — с версией политики и алертом на всплеск отказов.

И последнее: любые проверки описанных механизмов проводятся только на своей инфраструктуре или по письменному разрешению владельца с согласованным объёмом работ.

Источники

Что дальше

Мы разобрали, кто и что имеет право делать. Дальше — инструменты, на которых держатся сами механизмы доверия: чем симметричное шифрование отличается от асимметричного, зачем нужны подписи, почему хеш и KDF это разные вещи и как не изобрести собственный протокол.

Прикладная криптография: симметрика, асимметрика, хеши, подписи, KDF

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов