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

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

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

Аутентификация и авторизация — это разные вещи

Два слова, которые постоянно путают, а они про совершенно разное.

  • Аутентификация — это вопрос «кто ты?». Пользователь ввёл логин и пароль, 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 форму, и только в конце по ней принимается решение.

diagram

Коротко по шагам: администратор повесил роль → пользователь вошёл и роль попала в токен → токен приехал в ваш сервис с запросом → сервис достал роли из нужного места токена → перевёл их в свой внутренний формат → сверил с правилом доступа. Дальше — каждый шаг подробно.

Шаг 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 смотрит только в claim scope и делает из него authority с префиксом SCOPE_. А realm- и client-роли при этом просто игнорирует — как будто их нет.

Значит, между токеном и Spring нужен переводчик: компонент, который возьмёт роли из нужных мест токена и превратит каждую в GrantedAuthority. В Spring Security этот переводчик называется JwtAuthenticationConverter.

Аналогия. Токен — это паспорт, выписанный на чужом языке (на «языке Keycloak»). GrantedAuthority — это записи в анкете на понятном Spring языке. JwtAuthenticationConverter — переводчик, который читает паспорт и заполняет внутреннюю анкету Spring. Без переводчика Spring смотрит в паспорт и видит непонятные буквы.

Шаг 3: настраиваем переводчик ролей

Сначала — минимальная настройка resource server, то есть вашего приложения, которое принимает и проверяет токены. В application.yml указываем, кому верить:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/myrealm

По issuer-uri Spring сам найдёт JWKS (набор публичных ключей Keycloak) и будет проверять подпись каждого входящего токена. Это критично: подпись гарантирует, что роли в токене настоящие, а не подделанные. Проверка работает автоматически, ключи кешируются — вам тут писать ничего не надо.

Теперь сам переводчик. Достаём роли из realm_access.roles и навешиваем на каждую префикс ROLE_ (зачем именно ROLE_ — в следующем разделе):

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess == null) {
            return List.of();
        }
        @SuppressWarnings("unchecked")
        List<String> roles = (List<String>) realmAccess.get("roles");
        if (roles == null) {
            return List.<GrantedAuthority>of();
        }
        return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());
    });
    return converter;
}

После этого роль customer из токена превратится в authority ROLE_customer, которую Spring уже понимает и умеет сверять с правилами.

Обратите внимание: этот пример достаёт только realm-роли. Если вам нужны и 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.

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, Jwt jwt) {
        Order order = repository.findById(orderId).orElseThrow();
        String currentUserId = jwt.getSubject();   // claim "sub"

        if (!order.getOwnerId().equals(currentUserId)) {
            throw new AccessDeniedException("not your order");
        }
        order.changeAddress(address);
    }
}

Ключевое отличие от RBAC: здесь решение зависит от данных (кто владелец конкретного заказа), а не только от наличия роли. Такую проверку держат в одном месте — в сервисе или в отдельном компоненте доступа — а не размазывают копипастой по контроллерам.

Частый и разумный компромисс: роль admin проверку владения обходит — администратору можно трогать чужие записи (на то он и админ). Но каждое такое действие стоит записывать в журнал (audit), чтобы потом было видно, кто, когда и что менял в чужих данных.

RBAC и ABAC работают вместе

На практике эти два подхода не противопоставляют, а складывают. RBAC отсеивает грубо и рано, ABAC уточняет на уровне конкретной записи.

Что на схеме: запрос сначала проходит грубый фильтр по роли, и только если роль подошла — выполняется дорогая проверка владельца записи.

diagram

Порядок здесь не случайный, а ради экономии. Грубый фильтр по роли стоит дёшево (проверить строку в памяти) и срабатывает первым — если роли нет, мы отказываем сразу, не трогая базу. Дорогая проверка (сходить в базу, загрузить запись, сравнить владельца) выполняется только тогда, когда роль уже подошла. Так мы не нагружаем базу запросами, которые всё равно отвалятся на уровне роли.

При чём тут 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, а не новая строка в каталоге ролей. Чем меньше и стабильнее каталог, тем понятнее код доступа и тем меньше шансов, что где-то забудут добавить очередную роль в очередную проверку.

Каждый endpoint обязан иметь проверку

Это, пожалуй, самое важное правило всей темы — и одновременно то, про которое легче всего забыть. Звучит оно просто: у каждого метода контроллера должна быть явная проверка доступа (@PreAuthorize). Без исключений.

Почему так строго? Потому что метод без @PreAuthorize открыт любому аутентифицированному пользователю. Если токен валиден — Spring пускает, и неважно, какие у человека роли. То есть забытая аннотация — это не «доступ чуть шире, чем надо», а «доступ есть у всех, у кого вообще есть токен».

Особенно коварно это с админскими методами. Кажется, будто адрес вроде /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). Такой тест превращает договорённость «не забывай про проверку» в гарантию: забыть теперь физически нельзя — сборка не пройдёт.

Частые ошибки

Соберём грабли, на которые наступают чаще всего — почти все они уже встречались выше по тексту.

  • Ищут роли не в том 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); авторизация — «что тебе можно» (это решает ваше приложение по ролям).
  • Путь роли целиком: администратор назначил → роль попала в токен → сервис достал её из токена → перевёл в GrantedAuthority → сверил с правилом доступа.
  • Realm-роли лежат в realm_access.roles, client-роли — в resource_access.<client>.roles, права приложения — в scope. Это разные места — ищите там, где надо.
  • Spring из коробки роли Keycloak не видит — нужен JwtAuthenticationConverter, который маппит их в GrantedAuthority.
  • hasRole('x') сам добавляет ROLE_; hasAuthority('y') ищет точное имя. Это разные вещи — отсюда большинство ошибок с доступом.
  • RBAC — доступ по роли через @PreAuthorize (+ обязательно @EnableMethodSecurity); контроль на уровне действия.
  • ABAC — доступ по атрибутам; чаще всего это проверка владения: сравнить владельца записи с sub из токена.
  • На практике RBAC и ABAC складывают: роль фильтрует рано и дёшево, проверка владения уточняет на уровне конкретной записи.
  • Роли в JWT — это просто текст; доверять им можно только после проверки подписи через issuer-uri/JWKS.

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

  • Realm, client, роли и пользователи в Keycloak — откуда берутся роли и как их настраивают в Keycloak.
  • Keycloak и Spring Security: проверка токенов — как сервис принимает токен и проверяет его подпись по JWKS.
  • Токены Keycloak: проверка, refresh, отзыв и ошибки — из чего состоит JWT и что чаще всего ломается.