← назад к разделу

Keycloak проверил пароль и выдал пользователю токен — отлично. Но дальше встаёт неприятный вопрос: а что этому пользователю вообще можно? Может ли он удалять чужие заказы? Видеть админскую панель? Сам по себе токен не запрещает ничего — он лишь говорит, кто пришёл и с какими ролями. Превратить «у тебя есть роль» в «тебе сюда нельзя» — это уже задача вашего приложения, и Keycloak за вас её не сделает.

Разберём с самого нуля и по шагам: откуда берутся роли, как они попадают в токен, как Spring их оттуда достаёт и в какой момент принимается решение «пускать или нет».

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

доступ = что прочитал конвертер + как написан hasRole access_token от Keycloak realm_access.roles ["customer", "premium"] конвертер читает отсюда resource_access.orders-service roles: ["order-manager"] конвертер сюда не заглянул конвертер читает и отсюда JwtAuthenticationConverter читает realm_access, ROLE_ не ставит читает realm_access, ставит ROLE_ читает оба claim, ставит ROLE_ authorities в SecurityContext customer · premium ROLE_customer · ROLE_premium ROLE_customer · ROLE_premium · ROLE_order-manager hasRole('customer')ищет ROLE_customer hasRole('ROLE_customer')ищет ROLE_ROLE_customer 403: есть customer, нет ROLE_customer 200: ROLE_customer найден 403: ROLE_ROLE_customer не выдавали hasRole('order-manager') ищет ROLE_order-manager 403: ни префикса, ни client-роли 403: resource_access не читали 200: ROLE_order-manager найден роли в токене есть — доступа нет: конвертер не поставил ROLE_ префикс поставили: realm-роль прошла, client-роль всё ещё потеряна читаем оба claim и ставим ROLE_ — проходят обе проверки hasRole сам добавляет ROLE_ — с префиксом в аргументе выходит ROLE_ROLE_

Три роли в токене лежат по двум разным ключам, и доступ появляется только там, где конвертер прочитал нужный ключ и поставил 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_ и на самом деле ищет authority ROLE_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 ломает постраничную выдачу. Для списков — фильтр в запросе, для объекта — загрузка с владельцем; обход для администратора живёт в одном компоненте доступа и оставляет запись в журнале.

Что почитать дальше