Авторизация как код, но не в коде: ABAC для Spring Boot на Open Policy Agent


Привет! Меня зовут Дмитрий Коновалов, я разрабатываю внутреннюю платформу разработки (Internal Developer Platform, IDP) в ИТ-команде «Северстали». За несколько лет многое поменялось — и в продукте, и в компании. Но одну вещь за всё это время ни разу не переписывали с нуля, она только эволюционировала: модель доступа. Тема авторизации оказалась настолько интересной, что я сделал свой open-source Spring Boot starter — о нём и хочу рассказать в серии статей.
Содержание
Почему не работает то, что уже есть —
@PreAuthorize+ SpEL, Spring Security ACLЧто реально пишет разработчик — аннотация, интеграционный контракт, политика
Фейл: как мы убрали запасной путь — и легли все type-level гейты
Предвосхищая комментарии — Keycloak/Cerbos/OpenFGA · Kyverno · ReBAC vs ABAC · латентность · OPA упал · ошибка в политике
1. Кейс, на котором всё ломается
Представьте обычный B2B-сервис — каталог товаров. Три уровня: каталог → дерево категорий → товар.
Требования от продукта звучат буднично:
команда «Электроника» должна видеть и редактировать только свой каталог. Других каталогов для неё как будто не существует — ни в списках, ни по прямой ссылке;
права выдаются на каталоге, но действуют на всём поддереве. Грант «редактор каталога» означает право править любую категорию и товар под ним — на сколько угодно уровней вглубь;
у ролей бывают условия по атрибутам: роль «контент-менеджер по бытовой технике» видит только товары с тегом
department: appliances;и самое весёлое:
GET /catalogs/{id}/categoriesдолжен вернуть только те строки, которые этому субъекту можно видеть. Не 403 на всю коллекцию, а нормальный отфильтрованный список — желательно прямо фильтром в SQL, а не выгрузкой всей таблицы в память с последующим просеиванием.
Каждое требование по отдельности решаемо. Все вместе — это уже ABAC (attribute-based access control) поверх иерархического ресурса с фильтрацией данных. И вот этого из коробки в Spring-экосистеме нет.
2. Почему не работает то, что уже есть
@PreAuthorize + SpEL
@PreAuthorize("hasRole('CATALOG_EDITOR') and @catalogGuard.canEdit(#catalogId, principal)")
Три проблемы, и все три — структурные
Логика авторизации размазана по аннотациям. Правило «кто может редактировать категорию» существует в стольких экземплярах, сколько эндпоинтов её касаются. Аудитору безопасности придётся проревьюить все Spring MVC-контроллеры.
SpEL не тестируется как политика. Можно поднять контекст и проверить эндпоинт целиком, но нельзя взять само правило и прогнать по нему таблицу кейсов «субъект × ресурс × действие».
Иерархии и фильтрации списков просто нет.
hasRoleничего не знает про «грант на родителе». А для задачи «верни только доступные строки» SpEL бессилен — он умеет только да или нет на весь вызов.
Spring Security ACL
Формально в ACL-модуле есть per-object права и даже наследование. На практике это четыре таблицы ACL_*, в которых материализуется декартово произведение «объект × субъект × право».
Новый товар — новые записи в ACL. Поменяли роль — каскад апдейтов. Когда товаров миллион, а доступ ещё и от тега зависит (и этот тег можно менять), ACL превращается в отдельный ETL-конвейер синхронизации прав. Который обычно пытается догнать реальность и проигрывает.
3. Почему OPA — три аргумента, не обзор
Open Policy Agent — CNCF graduated движок политик. Политики пишутся на языке Rego, приложение спрашивает по HTTP: «Можно ли?», OPA отвечает решением.
Почему именно он, а не свой микросервис правил:
Политика — отдельный тестируемый артефакт.
opa testгоняет юнит-тесты по правилам с coverage, без Java-контекста. В репозитории 423 policy-тестов проходят за доли секунды. И это тесты именно правил: «редактор с тегом X не видит товар без тега», а не «эндпоинт вернул 403».Decision log из коробки. Каждое решение — структурированное событие: кто, что, над чем, почему. Для аудита это разница между «grep по логам приложений» и «выгрузка потока решений».
Один язык политик на всех уровнях. Тот же Rego, который решает «можно ли редактировать товар», работает на API-gateway (грубая проверка «пускать ли вообще»), в сервисе (точная проверка на ресурсе), а в третьей части серии он же будет решать, какие MCP-инструменты доступны AI-агенту.
4. Архитектура: путь одного запроса

Это общая топология стенда (в ней уже виден MCP-сервер из третьей части). А вот путь одного запроса через два слоя решений:
PUT /catalogs/{cid}/categories/{id}
│
▼
APISIX (gateway) ──── OIDC: валидация JWT (Keycloak)
│ └─ OPA, слой 1: грубая политика (gateway.rego) —
│ «этому типу субъекта на этот маршрут можно в принципе?»
▼
catalog-management-service (Spring Boot)
│ @OpaPreAuthorize на методе контроллера
│ └─ AuthorizationManager собирает контекст: субъект из JWT,
│ действие, ресурс + его предки, определение роли
│ └─ POST /v1/data/category → OPA, слой 2: точное решение
│ (category.rego: гранты, иерархия, теги, deny-overrides)
▼
PostgreSQL — а для списков ещё и слой 2b: политика, скомпилированная
в WHERE-условия (partial evaluation → JPA Specification, часть 2)
Два слоя решений — не дублирование. Gateway отсекает грубое («анонимусу в админ-API нельзя»), сервис решает точное («этому пользователю над этим товаром с этими тегами»). Политики обоих слоёв лежат рядом, на одном языке, тестируются одним инструментом.
5. Что реально пишет разработчик
Аннотация
Вот реальный контроллер ProductController.java (сокращено; полный файл — в репозитории, example-catalog-management-service/src/main/java/dev/dmitriikonovalov/example/catalog/web):
@OpaPreAuthorize(action = "product:view", resourceType = "'product'", resourceId = "#productId")
public ResponseEntity<Product> getProduct(UUID catalogId, UUID categoryId, UUID productId) { ... }
@OpaPreAuthorize(action = "product:list", resourceType = "'product'",
roleResourceType = "'catalog'", roleResourceId = "#catalogId")
public ResponseEntity<ProductPage> listProducts(UUID catalogId, UUID categoryId, ...) { ... }
Атрибуты — SpEL (отсюда кавычки в "'product'": строковый литерал, а #productId — параметр метода). Но обратите внимание, чего здесь нет: ни одного правила. Аннотация декларирует вопрос («может ли субъект выполнить product:view над этим ресурсом?»), а ответ целиком живёт в Rego.
Пара roleResourceType / roleResourceId во втором примере — подсказка «роль субъекта ищи на каталоге»: у списка товаров нет конкретного экземпляра, а грант живёт на корне поддерева (подробнее — в разделе про иерархию и в разделе про фейл ниже).
Интеграционный контракт
AuthorizationManager за аннотацией собирает AbacContext и отправляет его в OPA как input:
{
"input": {
"subject": { "id": "u-42", "roles": ["…"], "attributes": { … } },
"action": "product:view",
"resource": {
"type": "product",
"id": "p-1001",
"attributes": { "department": "appliances", … },
"ancestors": [ // root-first, без самого листа
{ "type": "catalog", "id": "c-7" },
{ "type": "category", "id": "cat-33" }
]
},
"role_definition": { … } // определение роли субъекта на governing root
}
}
Это и есть весь интеграционный контракт: приложение отвечает за атрибуты (кто, что, над чем, в каком окружении), политика — за решение.
Откуда берутся атрибуты:
субъект — из JWT;
ресурс и родительские сущности — из БД через SPI-резолвер (для иерархии — одно индексированное чтение ltree-пути, не рекурсивный обход);
определение роли — из user-service (роль ↔ команда ↔ ресурс).
Политика
Ядро реального product.rego (сокращено; полный файл — в репозитории, infra/opa/policies/):
package product
import data.permissions
default allow := false # запрет по умолчанию — явный
# итог: грант (прямой или унаследованный), НЕ перекрытый запретом
allow if {
granted
not denied
}
# прямой грант: verb входит в эффективные действия роли для этого типа ресурса
direct_grant if {
verb in permissions.effective_actions(input.role_definition, input.resource.type)
tags_satisfied
}
# запрет-переопределение: явный deny на листе побеждает любой грант
denied if {
input.resource.attributes.abac_deny == true
}
Роль субъекта несёт грубые категории прав (READ / WRITE / TAG / GRANT / CONTROL) по типам ресурсов. Общий модуль permissions разворачивает их в конкретные действия по таблице:
{ "READ": ["view", "list", "list-members"],
"WRITE": ["create", "update", "delete"],
"TAG": ["define-tags", "assign-tags"],
"GRANT": ["assign-roles"],
"CONTROL": ["add-member", "change-role", "remove-member"] }
…и сужает через denied_actions. Неизвестный токен категории разворачивается в пустое множество — опечатка в данных роли не может дать лишних прав, только отнять (fail-closed).
6. Иерархия: грант на родителе — в Rego, не в Java
Требование «редактор каталога редактирует всё поддерево» — это ровно два правила и одна декларация данных:
# унаследованный грант: роль несёт этот verb на предке, чей тип объявлен наследуемым
inherited_grant if {
some ancestor in input.resource.ancestors
data.product.inheritable[input.resource.type][ancestor.type] # наследование — opt-in
verb in permissions.effective_actions(input.role_definition, ancestor.type)
}
{ "product": { "inheritable": { "product": { "category": true, "catalog": true } } } }
Свойства, которые дались бесплатно:
opt-in, по умолчанию выключено. Нет декларации в
data.*.inheritable— нет наследования. Политика ведёт себя ровно как до-иерархическая. Наследование включается данными, не кодом;deny-overrides. Явный запрет на листе (
abac_deny) побеждает любой грант предка — правилоnot deniedстоит НАД обоими путями гранта;fail-closed. Если цепочка предков не пришла во входных данных или не прошла проверку, наследование просто не срабатывает — остаётся только прямой грант на самом ресурсе. Ошибка в данных сужает доступ, а не расширяет его;
в Java-коде про иерархию — ноль строк: сервис лишь кладёт цепочку предков в input.
7. Фейл: как мы убрали запасной путь — и легли все type-level гейты
В ранних версиях примера был удобный запасной путь — классический fallback: если у субъекта не нашлось определения роли (он не состоит в команде), политика проверяла realm-роли из JWT. Для демо — удобно. И это дыра в безопасности: доступ по прямой ссылке к ресурсу чужой команды («deep-link leak») проходил у любого субъекта с подходящей realm-ролью.
Решение: членство в команде — единственный путь доступа. Запасной путь убрали.
Удалили — и упали все type-level проверки: список, создание, назначение тегов при создании.
Причина в асимметрии, которую запасной путь маскировал. У решения «можно ли product:view над товаром p-1001» есть экземпляр товара, по которому резолвится роль. У решения «можно ли product:create» экземпляра нет — создаваемый товар ещё не существует, резолвить роль не на чем. Пока запасной путь существовал, type-level решения тихо проезжали на realm-ролях. Убрали — они стали fail-closed и честно закрылись для всех.
Починка — та самая пара в аннотации из раздела 5:
@OpaPreAuthorize(action = "product:create", resourceType = "'product'",
roleResourceType = "'catalog'", roleResourceId = "#catalogId")
«Определяй роль субъекта на родительском каталоге» — governing root известен из URL, даже когда экземпляра ещё нет. А в политике type-level запрос идёт через отдельное правило, тоже fail-closed:
# type-level запрос: id отсутствует ИЛИ явный null — оба значат «решение на уровне типа»
is_type_level_request if not input.resource.id
is_type_level_request if input.resource.id == null
allow if {
is_type_level_request
not denied
list_inheritable_grant # verb гранта — на объявленном наследуемом предке
}
Мораль, ради которой раздел и написан: запасные пути в авторизации маскируют структурные дыры контракта. Пока правило «а если роли нет — посмотрим в JWT» существует, вы не узнаете, какие из ваших проверок вообще не умеют резолвить роль. Единственный способ узнать — убрать запасной путь и посмотреть, что упадёт. Лучше на тестовом окружении, чем в проде.
8. Запустить за пять минут
git clone https://github.com/Void3110/spring-boot-starter-opa-abac
cd spring-boot-starter-opa-abac
# полный контур: APISIX → OPA → сервисы → Postgres (+ Keycloak, Jaeger) — всё включено по умолчанию
./profile.sh up
./deploy.sh up --pods 2
# gateway: http://localhost:9085
# allow/deny-матрица через gateway — иерархия, роли, теги:
cd scripts/postman && ./run-hierarchy-matrix.sh
# то же самое глазами — демо-консоль в браузере:
./seed-demo-data.sh # демо-команда, роли, каталог — один раз на свежий стенд
open http://localhost:9085 # войдите как editor / demo / viewer / outsider (пароль = логин)
Матрица — это newman-прогон с двумя токенами (разные субъекты), который на каждую пару «эндпоинт × субъект» ассертит и allow-, и deny-ветку. Не «запрос прошёл», а «этому можно, а вот этому — вот с таким кодом нет». Убедиться, что авторизация работает, можно только увидев, как она отказывает.
Демо-консоль показывает всё то же визуально: одни и те же кнопки появляются, блокируются или честно отвечают 403 в зависимости от того, кто вошёл. Для сборки консоли нужен Node/npm; без него — ENABLE_SPA=0 ./deploy.sh up.
9. Предвосхищая комментарии
«Авторизация — решённая проблема: Keycloak, Cerbos, OpenFGA, чистый OPA. Зачем ещё библиотека?»
Разделим движок и интеграцию. Движок мы не писали — решения принимает upstream OPA, и это принципиально: базируемся на CNCF-стандарте, а не на самописном ядре.
Keycloak отвечает на другой вопрос — «Кто ты?» (identity, токены), а не «может ли этот субъект редактировать эту категорию с этими тегами». Cerbos и OpenFGA — достойные движки, но это отдельные сервисы со своей моделью политик: их точно так же надо интегрировать в Spring, и ни один не даёт фильтрацию списков на уровне SQL для JPA-слоя.
Стартер — это ровно тот интеграционный слой, который каждая команда пишет заново: сбор контекста атрибутов, резолв иерархии предков, fail-closed обвязка, partial-eval → Specification. Не «велосипед», а адаптер поверх стандарта.
«Почему не Kyverno? Он тоже CNCF graduated, и политики у него проще Rego»
Kyverno — сильный движок для того, для чего он создан: admission-контроль Kubernetes (валидация и мутация манифестов, верификация образов, cleanup). С марта 2026 — действительно тоже CNCF graduated. Была бы задача «не пускать в кластер под без resource limits» — взяли бы Kyverno и этой серии бы не было.
Но точка принятия решения здесь другая: «может ли этот субъект выполнить это действие над этим ресурсом с этими атрибутами» — внутри Spring-приложения, на каждый запрос, с резолвом иерархии предков и роли.
Что у Kyverno есть за пределами admission: JSON-режим ValidatingPolicy (политики на CEL над произвольным JSON) и Kyverno Authz Server — ext_authz-фильтр для Envoy. Второе ближе всего к нашей задаче, но, во-первых, это прокси-слой: в архитектуре из раздела 4 это «слой 1» — грубый гейт на gateway, а не точное решение на ресурсе с атрибутами из БД. Во-вторых, на момент написания это v0.4.0 с API v1alpha1 и честным предупреждением «in development stage» в README — ставить на это единственный слой авторизации продакшн-сервиса рано.
И главное, что определило выбор: partial evaluation. Вся вторая часть серии — компиляция той же политики в WHERE-условия для JPA — существует потому, что у OPA есть Compile API: политика частично вычисляется, невычислимый без данных остаток становится SQL-фильтром. У Kyverno/CEL эквивалента нет: CEL вычисляет выражение над полным входом, «остаточной» компиляции политики в фильтр данных там не существует как класса задач. Плюс тестирование: opa test с coverage гоняет наши 423 policy-тестов без Java и без кластера — тестовый инструментарий Kyverno построен вокруг Kubernetes-ресурсов.
Выбор движка сделала не идеология, а эти две вещи.
«У вас команды, membership и иерархия — это же ReBAC, а не ABAC. Почему тогда не OpenFGA/SpiceDB?»
Разведём термины. ReBAC (relationship-based; модель Google Zanzibar, движки OpenFGA и SpiceDB) отвечает на вопрос «есть ли путь в графе отношений»: движок хранит кортежи (субъект, отношение, объект) и решает задачу достижимости по ним. ABAC (NIST SP 800-162) отвечает «истинен ли предикат над атрибутами субъекта, действия, ресурса и окружения», причём атрибуты приносят в момент решения.
Отношения при этом никуда не деваются: членство в команде и цепочка предков — это отношения. Разница не в том, есть ли в модели связи, а в том, кто хранит граф и кто по нему ходит.
У нас граф хранит приложение — там, где он и так живёт: иерархия — ltree-путь в Postgres, membership — таблица user-service. Приложение разворачивает отношения в атрибуты входа (resource.ancestors; role_definition, резолвнутое по membership на governing root), а OPA вычисляет чистый предикат над этим документом. Так что честный ответ на «что тут реализовано»: ABAC-движок поверх relationship-derived атрибутов.
«Команда имеет роль на каталоге» — концептуально это ReBAC-связь, но вычисляет её приложение (одно индексированное чтение пути + один внутренний вызов), а не графовый поиск внутри движка.
Почему не Zanzibar: OpenFGA/SpiceDB хорошо подходят, когда граф глубокий, гетерогенный и управляется самими пользователями — шеринг произвольных объектов в стиле Google Docs. Цена — отдельное хранилище кортежей, в которое надо синхронизировать каждое изменение иерархии и membership (классический dual-write; в Zanzibar не зря придумали zookie ради консистентности).
У нас видов отношений два, оба стабильной формы, оба уже лежат в Postgres — вторая база с копией графа — чистый оверхед. Дальше — условия по атрибутам: «видит только товары с тегом department: appliances» для ReBAC-движков — пристройка (conditions в OpenFGA, caveats в SpiceDB), а для ABAC — сама модель. И последнее — списки: tuple-движки отвечают на «что видно» реверс-индексом (ListObjects → набор id → WHERE id IN (…)), а наш ключевой сценарий — компиляция предиката политики в SQL (partial evaluation, часть 2), где условие по тегам попадает в WHERE выражением, а не перечислением идентификаторов.
Если ваш продукт — шеринг произвольных объектов между произвольными субъектами, берите OpenFGA/SpiceDB и не мучайтесь. Если доступ — это «роль на корне поддерева + условия по атрибутам», ABAC с relationship-derived атрибутами закрывает задачу без второго хранилища.
«Вы добавили сетевой вызов в каждый запрос. Сколько это стоит?»
Замерено, а не предположено: k6 через gateway, один и тот же эндпоинт с гейтом и без — +0,79 мс (+15 %) на p50, хвост статистически плоский. Методика и сырые цифры — в PERFORMANCE.md репозитория. Для списков решение принимается не per-row, а одной компиляцией политики в WHERE-условия (об этом — часть 2).
«А когда OPA упадёт — у вас ляжет весь доступ?»
Да — и это «by design». Fail-closed: нет ответа от OPA — запрос получает deny, типизированный 403 problem+json, не 5xx и не зависание (таймаут ограничен). В fault-драйве с жёстко подвешенным OPA: все отказы типизированы, восстановление после возврата OPA — 0,36 с. Альтернатива — fail-open, «при сбое авторизации пускать всех» — для слоя доступа не рассматривается.
«Ошибка в политике — и у вас дыра в проде?»
У ошибки политики два исхода: лишний запрет и лишний доступ. Первый — «шумный» (пользователь видит 403 и заводит тикет), второй — «тихий» (никто ничего не замечает). Поэтому каждый дефолт в системе выбирает «шумный» исход: default allow := false; неизвестный токен категории прав разворачивается в пустое множество, а не игнорируется; политики тестируются как код (opa test, в репозитории 423 policy-тестов, гоняются в CI). Ложный deny чинится за часы; тихий allow живёт до аудита.
«А мой вопрос вы не предвосхитили»
Тогда он — лучший из возможных. Пишите в комментариях: какой аргумент не убедил, какую альтернативу я обошёл, где вы бы сделали иначе. На самые сильные возражения отвечу отдельным разделом во второй части — с кодом, а не общими словами.
Что дальше
Точечная проверка «можно ли субъекту X действие Y над ресурсом Z» — это половина ABAC. Вторая половина — списки: GET /products должен вернуть только видимые строки, фильтром в SQL.
Во второй части — как та же Rego-политика через partial evaluation (Compile API) превращается в JPA Specification, что происходит с ней в SQL и почему непереводимый остаток должен ронять запрос, а не пропускать строки.
В третьей — что меняется, когда вызывающий — не человек, а AI-агент с MCP-инструментами, и почему это два субъекта в одном bearer-токене.
Библиотека: dev.dmitriikonovalov:opa-abac-spring-boot-starter (Maven Central) ·
github.com/Void3110/spring-boot-starter-opa-abac ·
Spring Boot 4.0 / Java 25 / OPA 1.x · Apache-2.0
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.