Keycloak проверил пароль и выдал пользователю токен — отлично. Но дальше встаёт неприятный вопрос: а что этому пользователю вообще можно? Может ли он удалять чужие заказы? Видеть админскую панель? Сам по себе токен не запрещает ничего — он лишь говорит, кто пришёл и с какими ролями. Превратить «у тебя есть роль» в «тебе сюда нельзя» — это уже задача вашего приложения, и Keycloak за вас её не сделает.
Разберём с самого нуля и по шагам: откуда берутся роли, как они попадают в токен, как Spring их оттуда достаёт и в какой момент принимается решение «пускать или нет».
Сначала посмотрим, чем всё кончается на практике: один и тот же токен с тремя ролями, четыре варианта настройки — и работает из них только один.
Три роли в токене лежат по двум разным ключам, и доступ появляется только там, где конвертер прочитал нужный ключ и поставил ROLE_, а в hasRole префикс не написан.
Аутентификация и авторизация — это разные вещи
Два слова, которые постоянно путают, а они про совершенно разное.
- Аутентификация — это вопрос «кто ты?». Пользователь ввёл логин и пароль, Keycloak его узнал и выдал токен. На этом аутентификация закончилась — личность установлена.
- Авторизация — это вопрос «что тебе можно?». Уже про доступ к конкретным действиям: создать заказ, посмотреть чужой профиль, зайти в админку.
Бытовая аналогия. Аутентификация — это охранник на входе в здание, который сверил ваше лицо с пропуском и пустил внутрь. Авторизация — это замки на дверях кабинетов внутри: пропуск открыл вам турникет, но не каждую дверь. Можно быть впущенным в здание (аутентифицирован) и при этом упереться в запертую дверь нужного кабинета (не авторизован).
Keycloak отвечает за первую часть — он узнаёт пользователя и кладёт в токен его роли. Вторую часть — «с такими ролями сюда можно, а сюда нельзя» — решает уже ваше приложение. Дальше вся статья именно про неё.
Откуда вообще берутся роли
Роль — это просто ярлык, который администратор Keycloak вешает на пользователя: customer, admin, order-manager. Сам по себе ярлык ничего не значит — это строка. Смысл ему придаёт уже ваше приложение, когда говорит «у кого ярлык admin, того пускаем в админку».
Keycloak различает два вида ролей, и эта разница важна, потому что они потом лежат в разных местах токена.
- Realm-роль — роль уровня всего realm. Realm — это одно изолированное «царство» пользователей в Keycloak. Realm-роль видят все приложения этого realm. Сюда вешают общие ярлыки вроде
customerилиadmin— те, что имеют смысл во всей системе. - Client-роль — роль, привязанная к конкретному client (приложению), зарегистрированному в Keycloak. Такой ярлык имеет смысл только внутри одного приложения. Например,
order-managerосмыслен только в сервисе заказов и не нужен остальным.
Если в двух словах: realm-роль — «он наш сотрудник вообще», client-роль — «он менеджер именно в этом приложении».
Как роль доезжает до проверки доступа: путь целиком
Прежде чем нырять в детали, посмотрим на весь путь сверху — от назначенной роли до решения «пустить или нет». Это главный сюжет статьи, и каждый его шаг мы дальше разберём отдельно.
Что на схеме: роль администратор назначает пользователю, она едет внутри токена в виде claim, ваш сервис достаёт её и превращает в понятную Spring форму, и только в конце по ней принимается решение.
Коротко по шагам: администратор повесил роль → пользователь вошёл и роль попала в токен → токен приехал в ваш сервис с запросом → сервис достал роли из нужного места токена → перевёл их в свой внутренний формат → сверил с правилом доступа. Дальше — каждый шаг подробно.
Шаг 1: где роли лежат внутри токена
Токен от Keycloak (а точнее — access_token, тот самый, что приходит в API в заголовке Authorization: Bearer ...) — это JWT (JSON Web Token). По сути это JSON-объект, подписанный сервером. Внутри лежат claims — поля с информацией о пользователе. Если декодировать токен (например, на jwt.io — там видно содержимое любого JWT), роли видно прямо внутри.
Помните про два вида ролей? Keycloak кладёт их в разные места токена — это ключевой факт, на котором спотыкаются чаще всего.
{
"sub": "a1b2c3d4-...",
"preferred_username": "ivan",
"realm_access": {
"roles": ["customer", "premium"]
},
"resource_access": {
"orders-service": {
"roles": ["order-manager"]
}
},
"scope": "openid profile email"
}
Что здесь что:
realm_access.roles— здесь лежат realm-роли. Это плоский список ярлыков уровня всего realm. В примере у пользователяivanесть realm-ролиcustomerиpremium.resource_access.<client>.roles— здесь лежат client-роли, причём сгруппированные по имени client. В примере под ключомorders-serviceлежит client-рольorder-manager. Если бы у пользователя были client-роли в другом приложении, подresource_accessпоявился бы ещё один ключ с именем того приложения.scope— это не про роли пользователя вообще. Это про то, что разрешено самому приложению, которое действует от имени пользователя. К нему вернёмся отдельно ниже.sub— стабильный уникальный идентификатор пользователя в Keycloak. Пригодится в ABAC, чтобы понять, кто именно пришёл.
Главный практический вывод: realm-роли и client-роли лежат по разным ключам. Если ваше приложение ищет роли только в realm_access, оно никогда не увидит client-роли — и наоборот. А раз не увидело — решит, что роли у пользователя нет, и откажет в доступе там, где доступ должен быть.
Шаг 2: зачем роли «переводить» — GrantedAuthority
Тут важно понять одну вещь: Spring Security ничего не знает про Keycloak. Для него realm_access.roles — просто какое-то непонятное поле в JSON. Внутри себя Spring оперирует своим собственным понятием — GrantedAuthority («выданное право»). Это просто строка-метка вроде ROLE_admin или SCOPE_profile, которую Spring потом будет сверять с правилами доступа.
Проблема: Spring из коробки не умеет доставать роли из realm_access.roles — это формат, специфичный именно для Keycloak, а не часть стандарта. По умолчанию Spring заглядывает всего в два поля — сначала scope, потом scp (второе имя того же по смыслу поля, его используют некоторые серверы аутентификации), — и делает из найденного authority с префиксом SCOPE_. А realm- и client-роли при этом просто игнорирует — как будто их нет.
Значит, между токеном и Spring нужен переводчик: компонент, который возьмёт роли из нужных мест токена и превратит каждую в GrantedAuthority. В Spring Security этот переводчик называется JwtAuthenticationConverter.
Аналогия. Токен — это паспорт, выписанный на чужом языке (на «языке Keycloak»). GrantedAuthority — это записи в анкете на понятном Spring языке. JwtAuthenticationConverter — переводчик, который читает паспорт и заполняет внутреннюю анкету Spring. Без переводчика Spring смотрит в паспорт и видит непонятные буквы.
Шаг 3: настраиваем переводчик ролей
Настройка resource server с issuer-uri уже сделана в статье про интеграцию со Spring Security: по нему Spring находит JWKS и проверяет подпись каждого токена, а значит, роли в токене настоящие, а не подделанные. Осталось научить Spring эти роли читать.
Теперь сам переводчик. Он достаёт только realm-роли — из realm_access.roles — и навешивает на каждую префикс ROLE_ (зачем именно ROLE_ — в следующем разделе):
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
var realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return List.of();
}
@SuppressWarnings("unchecked")
var roles = (List<String>) realmAccess.get("roles");
if (roles == null) {
return List.of();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.map(GrantedAuthority.class::cast)
.toList();
});
return converter;
}
После этого роль customer из токена превратится в authority ROLE_customer, которую Spring уже понимает и умеет сверять с правилами.
Если нужны и client-роли, в том же конвертере придётся заглянуть ещё и в resource_access.<имя-client>.roles и добавить их в общий список — иначе они потеряются.
Шаг 4: главная путаница — префикс ROLE_
В Spring есть два очень похожих способа проверить право, и разница между ними ловит почти каждого новичка.
hasRole('customer')— Spring сам молча добавляет префиксROLE_и на самом деле ищет authorityROLE_customer.hasAuthority('ROLE_customer')— Spring ищет authority ровно как написано, ничего не добавляя.
То есть hasRole('customer') и hasAuthority('ROLE_customer') — это одно и то же. А вот hasRole('ROLE_customer') превращается в поиск ROLE_ROLE_customer (двойной префикс) и не сработает никогда.
Отсюда простое правило выбора при маппинге:
- если в конвертере вы добавили
ROLE_к ролям (как в примере выше) — пользуйтесьhasRole('customer'), без префикса в аргументе; - если не добавляли и authority называется просто
customer— тогда толькоhasAuthority('customer'), потому чтоhasRole('customer')будет искать несуществующийROLE_customerи молча откажет.
Самая частая ошибка во всей теме доступа: роли замаппили без префикса, а в коде написали hasRole('admin'). Доступ молча не работает — Spring ищет ROLE_admin, а в authority лежит просто admin, и они не совпадают. Никакой ошибки в логах при этом нет, просто 403. Чтобы об это не спотыкаться, удобнее один раз добавить ROLE_ в конвертере и везде писать hasRole.
Где физически стоит проверка: контроллер, сервис или обработчик
В примерах выше @PreAuthorize висит то на методе контроллера, то на методе сервиса. Это не небрежность: оба места рабочие, но отвечают они за разное, и путать их дорого.
На контроллере проверяют роль. Это ответ на вопрос «доступна ли такая ручка этому типу пользователя», он не зависит от данных и стоит дёшево: отказ происходит до похода в базу. Здесь же удобно читать карту доступа — открыл контроллер и увидел, кто что может.
В сервисе или обработчике команды проверяют то, для чего нужны данные: владение записью, её состояние, лимиты. Эту проверку нельзя поднять в контроллер, потому что там объекта ещё нет, а читать его дважды означает проверить одно состояние, а изменить другое.
Чего делать не надо — ставить одну и ту же проверку в оба места. Выглядит как защита в глубину, работает как расхождение: через полгода правило поменяют в одном месте, а во втором останется старое, и какое из них сработает, будет зависеть от того, кто кого вызвал. Правило простое: роль — на входе, данные — внутри, и каждая проверка живёт в одном месте.
Отдельно стоит случай, когда запрос приходит не через контроллер: обработчик сообщения из очереди, задача по расписанию, вызов другого сервиса. Проверка на контроллере их не видит вовсе, поэтому правила, которые обязаны действовать всегда, ставят там, где выполняется операция, а не на входе в HTTP.
Почему @PreAuthorize иногда не срабатывает
Аннотация работает не сама по себе, а через прокси: Spring подменяет бин обёрткой, которая проверяет права и только потом вызывает настоящий метод. Отсюда три случая, где проверки просто нет, и ни один не сообщает об этом при запуске:
- Вызов из того же класса.
this.cancel(id)идёт мимо обёртки — прокси стоит снаружи объекта, и внутренний вызов до него не доходит. Метод с@PreAuthorize, вызванный соседним методом того же бина, выполнится без всякой проверки. privateиfinal. Прокси переопределяет методы, аprivateиfinalпереопределить нельзя: аннотация на них не действует.- Объект создан руками.
new OrderService(...)в тесте или в фабрике — обычный объект без обёртки и без проверок.
Лечение одно: аннотацию ставят на публичный метод бина, который вызывают снаружи. Если проверка нужна внутри класса, её выносят в отдельный бин — тот самый компонент доступа — и вызывают как зависимость. Это ровно те же ограничения, что у @Transactional, и по той же причине; механика разобрана в статье про AOP.
RBAC: доступ по ролям
Теперь, когда роли доехали до Spring в виде authority, можно ими пользоваться. Самый простой и частый подход называется RBAC (Role-Based Access Control) — доступ по ролям. Логика прямая: «у тебя есть роль admin → пускаем в админку». Никаких дополнительных условий, только наличие нужного ярлыка.
В Spring это ставится аннотацией @PreAuthorize прямо над методом контроллера:
@RestController
class OrderController {
@PreAuthorize("hasRole('admin')")
@DeleteMapping("/orders/{id}")
void deleteOrder(@PathVariable Long id) { ... }
@PreAuthorize("hasRole('customer')")
@PostMapping("/orders")
void createOrder(@RequestBody OrderRequest req) { ... }
}
Важный нюанс, про который легко забыть: чтобы @PreAuthorize вообще что-то делала, метод-уровневую защиту надо явно включить:
@Configuration
@EnableMethodSecurity
class SecurityConfig { ... }
Без @EnableMethodSecurity аннотации @PreAuthorize просто игнорируются — Spring их не видит, метод вызывается всегда, а вам кажется, что защита стоит. Это коварная ошибка: код выглядит защищённым, а на деле дыра.
RBAC отвечает на вопрос «какому типу пользователей вообще можно сюда?». Это контроль на уровне действия (endpoint), а не на уровне конкретной записи. RBAC легко скажет «редактировать заказы может любой customer» — но он в принципе не способен проверить, свой ли это заказ. Вот для этого и нужен следующий подход.
ABAC: когда одной роли мало
Представьте ситуацию. У Ивана роль customer, и RBAC разрешает любому customer редактировать заказы. Иван открывает заказ Петра и меняет адрес доставки на свой. Роль-то у Ивана правильная — RBAC пропустит без вопросов. А доступ при этом совершенно неправильный: один пользователь залез в данные другого.
В чём корень проблемы: роль ничего не знает про конкретную запись. «Можно редактировать заказы» и «можно редактировать этот заказ» — это два разных вопроса, и роль отвечает только на первый.
ABAC (Attribute-Based Access Control) — доступ по атрибутам. Решение принимается не только по роли, но и по атрибутам: кто владелец ресурса, кто сам пользователь, какой статус у записи. Самый частый и понятный случай ABAC — проверка владения: совпадает ли владелец заказа с тем, кто пришёл в токене.
А кто именно пришёл — берём из claim sub токена (тот самый стабильный идентификатор пользователя):
@Service
class OrderService {
void updateAddress(Long orderId, Address address, String currentUserId) {
Order order = repository.findById(orderId).orElseThrow();
if (!order.getOwnerId().equals(currentUserId)) {
throw new AccessDeniedException("not your order");
}
order.changeAddress(address);
}
}
Обратите внимание, что в метод приходит не сам токен, а обычная строка currentUserId. Достать из токена sub — работа контроллера: там веб-уровень и заканчивается. Если протащить Jwt внутрь сервиса, вы привяжете бизнес-логику к тому, что запрос пришёл по HTTP с токеном Keycloak, — и этот же метод уже не вызвать ни из фонового задания, ни из теста без сборки фальшивого токена.
Ключевое отличие от RBAC: здесь решение зависит от данных (кто владелец конкретного заказа), а не только от наличия роли. Такую проверку держат в одном месте — в сервисе или в отдельном компоненте доступа — а не размазывают копипастой по контроллерам.
Частый и разумный компромисс: роль admin проверку владения обходит — администратору можно трогать чужие записи (на то он и админ). Но каждое такое действие стоит записывать в журнал (audit), чтобы потом было видно, кто, когда и что менял в чужих данных.
Проверка после выполнения: @PostAuthorize и @PostFilter
Иногда решение нельзя принять заранее: чтобы понять, ваш ли это заказ, его надо сначала прочитать. Для таких случаев есть две аннотации, работающие после метода.
@PostAuthorize смотрит на результат, который доступен в выражении как returnObject:
@PostAuthorize("returnObject.customerId == authentication.name")
public Order find(Long id) { ... }
@PostFilter проходит по коллекции и выбрасывает элементы, для которых условие ложно (filterObject — текущий элемент):
@PostFilter("filterObject.customerId == authentication.name")
public List<Order> findRecent() { ... }
Выглядит удобно, и поэтому стоит сразу сказать про цену.
Метод уже выполнился. Для чтения это значит, что чужой объект прочитан из базы и только потом выброшен: в журнале запросов он есть, в кэше он есть, в счётчиках он есть. Для изменения — что изменение уже произошло: @PostAuthorize на методе, который что-то записал, вернёт 403, но запись останется, если её не откатит транзакция. Поэтому @PostAuthorize — про чтение, а не про запись.
У @PostFilter цена выше, и она ломает не безопасность, а работу. Он отсекает после выборки, значит постраничная выдача врёт: запросили двадцать записей, выбрали двадцать чужих, вернули ноль — и клиент видит пустую вторую страницу вместо своих заказов. Чем больше данных, тем бесполезнее работа: сервис читает всю таблицу, чтобы отдать три строки.
Порядок выбора поэтому такой. Для списков — фильтр в самом запросе (findByCustomerId), и только он. Для одного объекта — загрузка сразу с владельцем (findByIdAndCustomerId): заодно получается 404 вместо 403, и существование чужой записи не выдаётся. @PostAuthorize оставляют для случаев, где условие сложное и объект всё равно читается целиком, а @PostFilter — для небольших коллекций, которые и так загружаются полностью.
RBAC и ABAC работают вместе
На практике эти два подхода не противопоставляют, а складывают. RBAC отсеивает грубо и рано, ABAC уточняет на уровне конкретной записи.
Что на схеме: запрос сначала проходит грубый фильтр по роли, и только если роль подошла — выполняется дорогая проверка владельца записи.
Порядок здесь не случайный, а ради экономии. Грубый фильтр по роли стоит дёшево (проверить строку в памяти) и срабатывает первым — если роли нет, мы отказываем сразу, не трогая базу. Дорогая проверка (сходить в базу, загрузить запись, сравнить владельца) выполняется только тогда, когда роль уже подошла. Так мы не нагружаем базу запросами, которые всё равно отвалятся на уровне роли.
Обход для администратора — в одном месте
Правило «у роли admin проверка владения не применяется» звучит просто, а в коде расползается: в каждом методе появляется if (isAdmin(auth)) { ... } else { проверить владельца }, и через полгода в одном месте из десяти условие забыли.
Держат его там же, где живёт сама проверка, — в компоненте доступа:
@Component("access")
public class OrderAccess {
public boolean canRead(Long orderId, Authentication auth) {
if (auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_admin"))) {
return true;
}
return orderRepository.existsByIdAndCustomerId(orderId, auth.getName());
}
}
Тогда правило читается в аннотации одной строкой — @PreAuthorize("@access.canRead(#id, authentication)") — и меняется в одном файле. Побочная польза: тест «администратор видит чужой заказ, покупатель нет» пишется на этот компонент, без Spring MVC и без базы.
Две оговорки, без которых обход превращается в дыру. Первая: обход нужен не всякому административному действию. Прочитать чужой заказ, разбирая жалобу, — да; менять чужую карточку от чужого имени — почти никогда, для этого есть отдельные административные ручки. Вторая: обход всегда оставляет запись в журнале, и это не пожелание — разбор в статье про журнал действий.
При чём тут scope
Иногда нужно проверить не роль пользователя, а разрешение самого приложения — то самое поле scope из токена. Разница тонкая, но важная: роль отвечает на вопрос «кто пользователь» (customer, admin), а scope — «что приложению разрешено делать от имени пользователя» (читать заказы, но не удалять).
Как мы уже выяснили, scope — единственное, что Spring достаёт из токена сам, без конвертера. Он делает из каждого scope authority с префиксом SCOPE_:
@PreAuthorize("hasAuthority('SCOPE_orders:read')")
@GetMapping("/orders")
List<Order> list() { ... }
Когда что использовать простыми словами: для обычного бизнес-доступа внутри ваших сервисов почти всегда хватает ролей. Scope чаще нужен в сценариях с внешними клиентами и публичными API, где важно ограничить именно то, на что согласился пользователь («это приложение может читать мои заказы, но не управлять ими»).
Сколько ролей вообще заводить
Раз роль — это просто ярлык, который легко создать в Keycloak парой кликов, возникает соблазн плодить их под каждый чих: customer, customer-premium, customer-trial, seller, seller-pro, partner-admin, junior-admin... Через полгода в каталоге два десятка ролей, никто не помнит, чем seller-pro отличается от seller, а проверки доступа превращаются в кашу из hasAnyRole(...) на пол-экрана.
Здоровая дисциплина — обратная: ролей должно быть мало и стабильно. Хороший каталог на типичный сервис умещается в несколько штук, например:
customer— конечный пользователь, создаёт и читает свои заказы;seller— продавец, управляет своими товарами;admin— внутренний сотрудник с расширенным доступом;system— служебная роль для вызовов «сервис к сервису».
И всё. Появление новой роли — это не рутина, а сигнал остановиться и подумать: точно ли это новый тип пользователя, или мы пытаемся ролью выразить что-то другое?
Чаще всего — другое. Разберём типичные случаи, когда «нужна новая роль» на самом деле ролью не является:
- «У premium-клиентов есть доступ к дополнительным функциям». Premium — это не отдельный сорт людей, а атрибут обычного
customer: у него есть или нет активная подписка. Это вопрос «что у этого пользователя за свойство», а не «кто он по роли». Решается это так же, как проверка владения из раздела про ABAC, — глядя на данные пользователя (есть ли активная подписка), а не на наличие отдельной роли. Заводить ради подписки рольcustomer-premium— значит зашивать бизнес-признак, который завтра поменяется, в неподвижный ярлык доступа. - «B2B-клиенты не должны видеть розницу». Если две группы пользователей работают с принципиально разными данными и сценариями — это, скорее всего, два разных сервиса (или две разные области внутри системы), а не две роли в одном.
- «Junior-admin может только смотреть, но не менять». Здесь речь не о новой роли, а о наборе прав внутри роли
admin. Дробить администратора на лесенку ролей (admin,admin-readonly,admin-billing...) — путь к тому же разрастанию; обычно это решают системой разрешений, а не множением ролей.
Простое правило: роль отвечает на вопрос «кто это вообще за пользователь», а всё, что звучит как «а ещё у него есть/нет такого свойства» — это атрибут, то есть территория ABAC, а не новая строка в каталоге ролей. Чем меньше и стабильнее каталог, тем понятнее код доступа и тем меньше шансов, что где-то забудут добавить очередную роль в очередную проверку.
Отдельно про имена, потому что в этой же статье они разные. В каталоге выше покупатель называется customer, а в разборе маркетплейса ниже — buyer, и рядом с ним стоят moderator, dispute-operator, finance вместо одного admin. Это не опечатка: короткий каталог показывает минимум, с которого начинают, а маркетплейс — то, во что он вырастает, когда внутренних сотрудников становится несколько групп с разной ответственностью и разными последствиями ошибки. Важно другое: имя роли — соглашение всей системы, а не отдельного сервиса. Если в одном сервисе покупатель customer, а в соседнем buyer, то однажды проверка не найдёт роль и откажет молча — или, что хуже, путь окажется открыт, потому что условие писали под другое имя. Имена заводят один раз, в одном realm, и держат в одном месте, куда смотрят все команды.
Каждый endpoint обязан иметь проверку
Это, пожалуй, самое важное правило всей темы — и одновременно то, про которое легче всего забыть. Звучит оно просто: у каждого метода контроллера должна быть явная проверка доступа (@PreAuthorize). Без исключений.
Почему так строго? Потому что метод без @PreAuthorize открыт всем, кого пропустила цепочка фильтров, — а она про роли ничего не знает. При обычном правиле anyRequest().authenticated() это значит «любой, у кого есть валидный токен»: неважно, какие у человека роли. А если путь попал под permitAll() — а такие пути есть почти в каждом конфиге, от health-check до открытого каталога, — то метод без аннотации открыт вообще всем, включая тех, кто не входил. То есть забытая аннотация — это не «доступ чуть шире, чем надо», а «доступ есть у всех, кого пустил фильтр».
Особенно коварно это с админскими методами. Кажется, будто адрес вроде /admin/orders/{id}/refund сам по себе что-то защищает — мол, «это же админский путь». Не защищает. URL — это просто строка, она ничего не запрещает. Если над методом возврата денег не висит @PreAuthorize("hasRole('admin')"), то возврат сможет инициировать любой обладатель токена, в том числе обычный customer. Дыра тем опаснее, что выглядит безобидно: код вроде на месте, путь «админский», а проверки нет.
Проблема в том, что забытую аннотацию глазами на ревью не всегда поймаешь — методов много, один пропустили, и всё. Поэтому такое правило удобно проверять автоматически, тестом. Есть библиотека ArchUnit — она умеет писать тесты не про поведение кода, а про его структуру: «все методы контроллеров обязаны иметь такую-то аннотацию». Один раз написали — и сборка падает, как только кто-то добавил endpoint без проверки:
@ArchTest
static final ArchRule everyEndpointHasPreAuthorize =
methods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
.and().areMetaAnnotatedWith(RequestMapping.class)
.should().beAnnotatedWith(PreAuthorize.class)
.because("endpoint без проверки доступа открыт любому, у кого есть токен");
Здесь areMetaAnnotatedWith(RequestMapping.class) ловит сразу все варианты — @GetMapping, @PostMapping, @PutMapping, @DeleteMapping (все они под капотом помечены @RequestMapping). Такой тест превращает договорённость «не забывай про проверку» в привычку, за которой следит сборка.
Только не считайте его непробиваемым: правило смотрит ровно на то, что в нём написано, — на классы с @RestController. Контроллер, написанный по-старому, через @Controller плюс @ResponseBody, под правило не попадёт и проедет мимо проверки. Поэтому условие правила стоит держать в том же виде, что и договорённость в команде: если у вас живут оба стиля — перечисляйте оба.
Частые ошибки
Соберём грабли, на которые наступают чаще всего — почти все они уже встречались выше по тексту.
- Ищут роли не в том claim. Realm-роли — в
realm_access.roles, client-роли — вresource_access.<client>.roles. Если читать толькоrealm_access, client-роли потеряются (и наоборот). - Забыли про префикс
ROLE_. Замаппили роль какadmin, а в кодеhasRole('admin')(Spring ищетROLE_admin). Доступ молча не работает. Либо добавляйтеROLE_в конвертере, либо пишитеhasAuthority. - Двойной префикс.
hasRole('ROLE_admin')ищетROLE_ROLE_admin. ВhasRoleпрефикс не пишут — Spring добавит его сам. - Полагаются на RBAC там, где нужен ABAC. Роль
customerне гарантирует, что заказ — его. Без проверки владения один пользователь правит данные другого. - Не включили
@EnableMethodSecurity. Без неё аннотации@PreAuthorizeпросто игнорируются — а кажется, что защита есть. - Доверяют ролям без проверки подписи. Роли в JWT — обычный текст внутри JSON. Без проверки подписи (через
issuer-uri/JWKS) их легко подделать. Никогда не парсите токен «руками» в обход resource server — пусть подпись проверяет Spring. - Проверили подпись — и решили, что всё. Подпись говорит только «токен выпустил наш Keycloak», но не «он выписан нам». Токен соседнего приложения того же realm — с настоящей подписью и с ролью
adminвнутри — пройдёт все проверки из этой статьи. Закрывается это проверкой аудитории: строкойaudiencesв настройках resource server (подробно — в статье про проверку токенов).
Разбор на маркетплейсе: где RBAC, а где ABAC
На абстрактном customer всё выглядит просто. Возьмём маркетплейс — площадку с покупателями, продавцами и сотрудниками — и посмотрим, какие проверки там реально нужны.
Начнём с ролей: buyer, seller, moderator, dispute-operator, finance. Их пять, и это весь каталог — как раз тот случай, когда ролей мало и они стабильны.
Теперь разложим действия по двум колонкам: что решается ролью, а что — атрибутами конкретной записи.
| Действие | Роль решает? | Что ещё нужно проверить |
|---|---|---|
| Оформить заказ | да, buyer | ничего: любой покупатель оформляет свой заказ |
| Посмотреть свой заказ | нет | владелец заказа = sub из токена |
| Отменить заказ | нет | владелец и статус: после отправки уже нельзя |
| Создать карточку товара | да, seller | ничего |
| Изменить карточку | нет | продавец карточки = sub: чужие карточки не свои |
| Одобрить карточку | да, moderator | ничего: модератор работает с любой карточкой |
| Решить спор | да, dispute-operator | спор должен быть взят этим оператором в работу |
| Подтвердить выплату | да, finance | сумма выше лимита — нужен второй подтверждающий |
Видно закономерность: действия сотрудников площадки почти целиком закрываются ролью, а действия покупателей и продавцов — почти никогда. Причина простая: сотрудник работает со всем потоком карточек и споров, а покупатель — только со своими заказами. Роль отвечает на вопрос «что за человек», и её достаточно ровно там, где ответ не зависит от конкретной записи.
Самая частая ошибка здесь — остановиться на роли для продавца. Проверка «есть роль seller» пропускает продавца к чужой карточке: роль-то у него правильная. Нужна вторая проверка — что карточка его:
@PreAuthorize("hasRole('seller')")
void updateCard(Long cardId, CardDraft draft, String currentUserId) {
Card card = cards.findById(cardId).orElseThrow();
if (!card.getSellerId().equals(currentUserId)) {
throw new AccessDeniedException("чужая карточка");
}
card.apply(draft);
}
Роль отсеивает покупателей сразу и дёшево — до похода в базу. Проверка владельца выполняется только для тех, кто прошёл первый фильтр.
Отмена заказа устроена ещё интереснее: там два атрибута, владелец и статус. Покупатель может отменить свой заказ, пока продавец не передал его курьеру; после этого отмена превращается в возврат, и правила другие. Написать это ролью нельзя в принципе — статус живёт в самом заказе:
void cancel(Long orderId, String currentUserId) {
Order order = orders.findById(orderId).orElseThrow();
if (!order.getBuyerId().equals(currentUserId)) {
throw new AccessDeniedException("чужой заказ");
}
if (!order.cancellable()) {
throw new IllegalStateException("заказ уже в доставке — это возврат, а не отмена");
}
order.cancel();
}
Разница между двумя отказами не косметическая. Первый — про доступ (403, «это не ваш заказ»), второй — про состояние (409, «в этом статусе нельзя»). Смешивать их не стоит: покупателю в интерфейсе нужно показать разные вещи.
Отдельно стоит выплата продавцу. Роль finance разрешает подтверждать выплаты, но у крупных сумм должно быть второе подтверждение — так устроена работа с деньгами почти везде. Это тоже атрибут (сумма) плюс правило «подтверждающий не тот же самый человек». Роль здесь только вход в проверку, а не вся она.
И общее правило, которое из этого следует: у сотрудников площадки права широкие, поэтому каждое их действие пишется в журнал — кто, когда, на каком заказе, что изменил. У покупателя журнал не нужен: он и так трогает только своё.
Глубже: Authorization Services: когда логика доступа уезжает в Keycloakрасширенное
У Keycloak есть третий способ решать «что можно», кроме ролей в токене и ABAC в коде: Authorization Services. На client включают «Authorization», и внутри появляются ресурсы (заказы, документы), области действий (view, cancel), политики (по роли, по пользователю, по группе, по времени, по атрибуту, составные) и разрешения, которые связывают ресурс, область и политику. Приложение спрашивает у Keycloak «можно ли этому пользователю cancel для order:42», получает токен с разрешениями (RPT) и по нему пускает или нет. Сверху лежит UMA 2.0: пользователь сам делится своим ресурсом с другим пользователем, и разрешение появляется без участия разработчика.
Почему для большинства сервисов это не берут. Логика доступа уезжает из кода в настройки другого сервиса: её не видно в ревью, её не покрывают тесты обработчика, её нельзя прочитать вместе с бизнес-правилом, которое она защищает. Проверка «владелец ли заказа» требует, чтобы Keycloak знал о каждом заказе как о ресурсе, то есть о миллионах строк вашей базы, либо ходил к вам за атрибутами. И появляется сетевой вызов на каждое решение или кэш решений с его устареванием. Старый адаптер keycloak-spring-boot-starter с policy enforcer снят с поддержки, остаётся низкоуровневая библиотека, и это тоже сигнал.
Когда всё-таки берут. Права настраивает не разработчик, а администратор заказчика, и меняются они еженедельно без выкатов: документооборот, порталы, где «кто какую папку видит» живёт в интерфейсе. Приложений много, и правила доступа обязаны быть общими для всех без дублирования кода. И сценарий UMA, когда пользователи делятся ресурсами друг с другом. Во всех остальных случаях достаточно ролей в токене плюс ABAC в обработчике, о чём вся эта статья.
Коротко
- Аутентификация — «кто ты» (это делает Keycloak), авторизация — «что тебе можно» (это решает приложение). Путь роли целиком: назначили → попала в токен → сервис достал → перевёл в
GrantedAuthority→ сверил с правилом. - Realm-роли лежат в
realm_access.roles, client-роли — вresource_access.<client>.roles, права приложения — вscope; Spring из коробки их не видит, нуженJwtAuthenticationConverter. hasRole('x')сам добавляетROLE_;hasAuthority('y')ищет точное имя. Это разные вещи — отсюда большинство ошибок с доступом.- RBAC — доступ по роли через
@PreAuthorize(плюс обязательный@EnableMethodSecurity), ABAC — по атрибутам, чаще всего владение. На практике их складывают: роль фильтрует рано и дёшево, владение уточняет на уровне записи. - Роли в JWT — это просто текст; доверять им можно только после проверки подписи через
issuer-uri/JWKS и проверки аудитории (aud) — иначе пройдёт чужой токен того же realm. - В маркетплейсе роль закрывает действия сотрудников, а действия покупателя и продавца — роль плюс владелец записи, иногда ещё и статус; при этом отказ по доступу (
403) и отказ по состоянию (409) — разные ответы. - Authorization Services и UMA переносят правила доступа в Keycloak: не видны в ревью и тестах, требуют знать о ресурсах или ходить за решением; берут, когда права правит администратор заказчика или пользователи делятся ресурсами, иначе роли плюс ABAC в коде.
- Роль проверяют на входе в контроллер, а владение и состояние — там, где есть данные; одну проверку в двух местах не дублируют, иначе они разойдутся.
@PreAuthorizeработает через прокси: вызов из того же класса,private,finalи объект, созданный руками, проверку не проходят вовсе — и молча.@PostAuthorizeи@PostFilterработают после метода: чужое уже прочитано, а@PostFilterломает постраничную выдачу. Для списков — фильтр в запросе, для объекта — загрузка с владельцем; обход для администратора живёт в одном компоненте доступа и оставляет запись в журнале.
Что почитать дальше
- Realm, client, роли и пользователи в Keycloak — откуда берутся роли и как их настраивают в Keycloak.
- Keycloak и Spring Security: проверка токенов — как сервис принимает токен и проверяет его подпись по JWKS.
- Токены Keycloak: проверка, refresh, отзыв и ошибки — из чего состоит JWT и что чаще всего ломается.
- Кейс: маркетплейс — сквозной пример сайта, на котором разобраны роли, владение и статусы в этой статье.