Авторизация как код, но не в коде: ABAC для Spring Boot на Open Policy Agent
Меня зовут Дмитрий Коновалов. Я занимаюсь разработкой внутренней платформы (Internal Developer Platform, IDP) в ИТ-команде "Северстали". За несколько лет менялись продукты, процессы и сама инфраструктура, но модель доступа оставалась практически неизменной: её не переписывали заново, а постепенно расширяли под новые требования.
В какой-то момент стало очевидно, что стандартных возможностей Spring Security уже недостаточно. Так появилась идея вынести правила авторизации в отдельный движок политик и оформить интеграцию с ним в виде Spring Boot Starter.
Когда обычной авторизации недостаточно
Рассмотрим типичный B2B-сервис с каталогом товаров. Его структура выглядит так:
- каталог;
- дерево категорий;
- товары внутри категорий.
На практике бизнес может сформулировать такие правила:
- команда "Электроника" видит и изменяет только собственный каталог;
- чужие каталоги не должны отображаться ни в списках, ни при обращении по прямому идентификатору;
- разрешение, выданное на каталог, распространяется на все вложенные категории и товары;
- роль может учитывать атрибуты объекта: например, контент-менеджер по бытовой технике работает только с товарами, имеющими тег `department: appliances`;
- запрос списка категорий должен возвращать не ошибку доступа, а только разрешённые строки;
- фильтрацию желательно выполнять на уровне базы данных, а не загружать всю таблицу в память.
Каждое из этих требований по отдельности решаемо. Однако вместе они образуют модель ABAC - авторизацию на основе атрибутов - поверх иерархических ресурсов и с фильтрацией коллекций.
Именно здесь традиционные подходы начинают давать сбои.
Почему `@PreAuthorize` и ACL быстро перестают справляться
Аннотации `@PreAuthorize` и выражения SpEL удобны для простых проверок:
```java
@PreAuthorize("hasRole('ADMIN')")
```
Но сложные правила начинают дублироваться по контроллерам и сервисам. Если один и тот же ресурс используется в нескольких эндпоинтах, логика доступа появляется в нескольких местах. Изменение бизнес-правила превращается в поиск всех связанных выражений.
Есть и другая проблема: SpEL-проверка обычно отвечает только на вопрос "разрешить или запретить вызов". Она не является самостоятельной политикой, которую можно независимо протестировать набором сценариев "субъект - ресурс - действие".
Особенно плохо этот подход подходит для коллекций. Проверка доступа к одному объекту ещё возможна, но запрос вида "верни только разрешённые товары" требует построения фильтра, а не простого результата `true` или `false`.
Spring Security ACL формально решает задачу объектных разрешений и поддерживает наследование. Однако на практике это означает хранение большого числа записей, связывающих субъектов, объекты и права.
При большом каталоге возникают типичные сложности:
- новый товар требует новых записей о доступе;
- изменение роли приводит к массовым обновлениям;
- изменение атрибута товара требует синхронизации разрешений;
- иерархическое наследование усложняет расчёт эффективных прав;
- база ACL постепенно превращается в отдельный конвейер материализации доступа.
Если разрешение зависит от изменяемых атрибутов, например от тега товара, такая модель начинает постоянно отставать от фактического состояния данных.
Почему выбран Open Policy Agent
Open Policy Agent - отдельный движок политик, в котором правила описываются на языке Rego. Приложение формирует входные данные и отправляет их в OPA, а тот возвращает структурированное решение.
Выбор OPA в подобной архитектуре обычно объясняется тремя причинами.
Политики можно тестировать отдельно
Правила не привязаны к Spring-контексту и конкретному HTTP-эндпоинту. Их можно проверять набором сценариев:
- пользователь с нужной ролью получает доступ;
- пользователь без нужного атрибута получает отказ;
- разрешение на родительский ресурс распространяется на дочерний;
- объект с неподходящим тегом исключается из результата.
Такие тесты быстрее интеграционных проверок и точнее показывают, какое именно правило нарушено.
Решения пригодны для аудита
OPA может формировать структурированные decision logs. В них можно фиксировать субъект, действие, ресурс, входные атрибуты и итоговое решение.
Это значительно удобнее, чем искать объяснение отказа в разрозненных логах нескольких сервисов.
Одинаковый механизм подходит для разных уровней
Одна и та же модель может применяться к:
- HTTP-запросам;
- операциям над отдельными объектами;
- фильтрации коллекций;
- публикации сообщений;
- административным операциям;
- инфраструктурным ресурсам.
При этом правила остаются за пределами бизнес-кода приложения.
Как проходит запрос
Типичная схема выглядит следующим образом:
1. Клиент обращается к Spring Boot-приложению.
2. Приложение аутентифицирует пользователя и извлекает его идентификатор, роли и атрибуты.
3. Компонент авторизации собирает входной объект для OPA.
4. В запрос включаются субъект, действие, тип ресурса, идентификатор объекта и его атрибуты.
5. OPA вычисляет политику.
6. Приложение получает решение.
7. В зависимости от результата запрос разрешается, отклоняется или преобразуется в фильтр для выборки данных.
Условный вход может выглядеть так:
```json
{
"subject": {
"id": "user-42",
"roles": ["catalog-editor"],
"department": "appliances"
},
"action": "read",
"resource": {
"type": "product",
"id": "product-17",
"catalog_id": "catalog-3",
"tags": ["department: appliances"]
}
}
```
Важно, чтобы этот формат был стабильным интеграционным контрактом. Если разные сервисы называют одни и те же поля по-разному, политика быстро превращается в набор исключений.
Что остаётся разработчику
После выноса решений в OPA разработчик не исчезает из процесса авторизации. Его задача меняется: вместо написания условий в контроллерах он описывает контракт ресурса и подключает проверку.
Обычно нужны три элемента:
1. аннотация или декларативная настройка;
2. преобразование доменной модели во входной объект политики;
3. сама политика Rego.
Например, endpoint может декларативно указывать действие и тип ресурса:
```java
@PolicyCheck(action = "read", resource = "product")
@GetMapping("/products/{id}")
public Product get(@PathVariable String id) {
return service.findById(id);
}
```
При этом контроллер не обязан знать, почему доступ разрешён. Он только сообщает, что требуется проверить.
Иерархия: наследование прав должно быть в политике
Если доступ выдан на каталог, не нужно вручную проверять всех родителей в Java-коде. Приложение передаёт в OPA сведения о ресурсе, а политика сама определяет, относится ли он к разрешённому поддереву.
Упрощённая логика может выглядеть так:
```rego
allow if {
input.action == "read"
input.subject.catalog_id == input.resource.root_catalog_id
}
```
В реальной системе правило может учитывать несколько уровней, состояние объекта, роль пользователя и дополнительные ограничения по атрибутам.
Преимущество такого подхода в том, что иерархия является частью модели доступа, а не скрытым поведением отдельных сервисных методов.
Фильтрация коллекций - отдельный сценарий
Проверка доступа к одному объекту и построение разрешённого списка - разные задачи.
Для одиночного ресурса достаточно ответа:
```json
{
"allow": true
}
```
Для коллекции могут потребоваться:
- список разрешённых идентификаторов;
- набор условий для SQL;
- предикаты по атрибутам;
- ограничения по владельцу или каталогу;
- комбинация `AND` и `OR`.
Прямо передавать произвольный SQL из политики опасно. Практичнее использовать ограниченный промежуточный формат, например:
```json
{
"filter": {
"catalog_id": "catalog-3",
"tags": {
"contains": "department: appliances"
}
}
}
```
Приложение преобразует этот результат в безопасный запрос средствами ORM или QueryDSL. Так политика определяет смысл ограничения, но не получает возможности выполнить произвольную команду в базе данных.
Ошибка при удалении запасного пути
Одна из самых опасных ошибок - считать, что type-level проверка полностью заменяет object-level авторизацию.
Например, endpoint может быть доступен роли `catalog-editor`, но конкретный каталог всё равно принадлежит другой команде. Если убрать проверку объекта, type-level gate пропустит запрос, а безопасность окажется нарушена.
Поэтому обычно нужны два уровня:
- проверка типа операции и общей роли;
- проверка конкретного ресурса, его владельца, родителей и атрибутов.
Нельзя автоматически считать объект разрешённым только потому, что разрешён его тип.
Как запустить такую схему
Минимальная интеграция обычно состоит из следующих шагов:
1. запустить OPA рядом с приложением;
2. загрузить в него политики;
3. определить единый формат входных данных;
4. добавить клиент OPA в Spring Boot;
5. подключить проверку через фильтр, аспект или интерцептор;
6. настроить обработку отказов и ошибок;
7. добавить тесты для политик;
8. включить логирование решений.
Для production-среды необходимо заранее определить поведение при недоступности OPA. Для операций чтения иногда допустима деградация, но для изменений безопаснее использовать fail closed: если решение получить невозможно, операция запрещается.
Latency и отказоустойчивость
Внешняя проверка добавляет сетевой вызов, поэтому латентность нужно учитывать с самого начала.
Снизить накладные расходы помогают:
- локальный экземпляр OPA рядом с сервисом;
- keep-alive-соединения;
- тайм-ауты;
- кэширование решений там, где это безопасно;
- пакетная проверка объектов;
- передача только необходимых атрибутов.
При этом кэш должен учитывать срок жизни разрешения и изменение ролей. Слишком агрессивное кэширование способно сохранить устаревший доступ.
OPA не должен становиться единственной точкой отказа. Для него предусматривают реплики, health-check, мониторинг, ограничение времени ответа и понятную стратегию отказа.
ABAC, ReBAC и централизованные системы
ABAC хорошо подходит, когда решение зависит от атрибутов субъекта, ресурса и контекста: подразделения, региона, тега, статуса или времени операции.
Если главным условием является отношение между сущностями - например, "пользователь является участником проекта", - более естественной может оказаться модель ReBAC. В реальных системах эти подходы часто комбинируют: отношения определяют базовый круг доступа, а атрибуты уточняют разрешение.
Системы управления идентичностью, графами отношений и политиками не обязательно конкурируют друг с другом. Keycloak или аналогичный поставщик может отвечать за аутентификацию и выдачу claims, а OPA - за принятие решений по доступу.
Главный вывод
ABAC на базе OPA не является волшебной заменой Spring Security. Это способ разделить ответственность:
- приложение отвечает за бизнес-операции и данные;
- система идентификации подтверждает личность и базовые признаки пользователя;
- OPA принимает решения по декларативным политикам;
- база данных выполняет безопасную фильтрацию;
- тесты проверяют правила независимо от контроллеров.
Такой подход особенно полезен, когда система имеет иерархические ресурсы, динамические атрибуты, сложные роли и требования к аудиту. Авторизация перестаёт быть набором разрозненных выражений в Java-коде и становится отдельным управляемым артефактом: версионируемым, тестируемым и пригодным для анализа.
