ESPN DeportesKawhi Leonard: ¿Cómo será recordado cuando finalice su carrera?Daily MaverickTHE GATHERING 2026: Fixing failing cities is in business’s own interest, leaders sayESPN'Gonna let them do them': Cunningham ignoring Kanter Freedom, Whiteוואלהחיל האוויר בגל תקיפות נגד יעדי טרור בדרום לבנוןRTP DesportoPortugal perde com Espanha e falha meias da Liga Europeia de futebol de praiaInquirerBojie Dy hails Pisa improvement: ‘It’s a welcome news’BlickVon Pfäffikon ZH über Siders VS bis Susch GR: Das sind die schlimmsten Busunglücke der Schweiz20 Minuten«Wir sind ein gutes Team»: Annemarie Carpendale lobt ihren WayneAntara NewsIndian envoy: BRICS agenda aligns with Indonesia bilateral focusBillboardFlavor Flav Shares Personal 9/11 Memory & Honors ‘Heroes Who Ran Toward Danger’ on 25th AnniversaryGlobal News13-year-old charged after firearm seized at Ajax hotel: Durham policeComplete Sports2026 US Open Final: Rybakina, Sabalenka Target Grand Slam Title
The Daily Newsstand · Free, Always
Friday, September 11, 2026

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

Translate

Привет! Меня зовут Дмитрий Коновалов, я разрабатываю внутреннюю платформу разработки (Internal Developer Platform, IDP) в ИТ-команде «Северстали». За несколько лет многое поменялось — и в продукте, и в компании. Но одну вещь за всё это время ни разу не переписывали с нуля, она только эволюционировала: модель доступа. Тема авторизации оказалась настолько интересной, что я сделал свой open-source Spring Boot starter — о нём и хочу рассказать в серии статей.

Содержание

  1. Кейс, на котором всё ломается

  2. Почему не работает то, что уже есть@PreAuthorize + SpEL, Spring Security ACL

  3. Почему OPA — три аргумента, не обзор

  4. Архитектура: путь одного запроса

  5. Что реально пишет разработчик — аннотация, интеграционный контракт, политика

  6. Иерархия: грант на родителе — в Rego, не в Java

  7. Фейл: как мы убрали запасной путь — и легли все type-level гейты

  8. Запустить за пять минут

  9. Предвосхищая комментарии — Keycloak/Cerbos/OpenFGA · Kyverno · ReBAC vs ABAC · латентность · OPA упал · ошибка в политике

  10. Что дальше

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)")

Три проблемы, и все три — структурные

  1. Логика авторизации размазана по аннотациям. Правило «кто может редактировать категорию» существует в стольких экземплярах, сколько эндпоинтов её касаются. Аудитору безопасности придётся проревьюить все Spring MVC-контроллеры.

  2. SpEL не тестируется как политика. Можно поднять контекст и проверить эндпоинт целиком, но нельзя взять само правило и прогнать по нему таблицу кейсов «субъект × ресурс × действие».

  3. Иерархии и фильтрации списков просто нет. hasRole ничего не знает про «грант на родителе». А для задачи «верни только доступные строки» SpEL бессилен — он умеет только да или нет на весь вызов.

Spring Security ACL

Формально в ACL-модуле есть per-object права и даже наследование. На практике это четыре таблицы ACL_*, в которых материализуется декартово произведение «объект × субъект × право».

Новый товар — новые записи в ACL. Поменяли роль — каскад апдейтов. Когда товаров миллион, а доступ ещё и от тега зависит (и этот тег можно менять), ACL превращается в отдельный ETL-конвейер синхронизации прав. Который обычно пытается догнать реальность и проигрывает.

3. Почему OPA — три аргумента, не обзор

Open Policy Agent — CNCF graduated движок политик. Политики пишутся на языке Rego, приложение спрашивает по HTTP: «Можно ли?», OPA отвечает решением.

Почему именно он, а не свой микросервис правил:

  1. Политика — отдельный тестируемый артефакт. opa test гоняет юнит-тесты по правилам с coverage, без Java-контекста. В репозитории 423 policy-тестов проходят за доли секунды. И это тесты именно правил: «редактор с тегом X не видит товар без тега», а не «эндпоинт вернул 403».

  2. Decision log из коробки. Каждое решение — структурированное событие: кто, что, над чем, почему. Для аудита это разница между «grep по логам приложений» и «выгрузка потока решений».

  3. Один язык политик на всех уровнях. Тот же 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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.