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

Когда пользователь отправляет запрос, приложение должно ответить на три разных вопроса:

  1. Кто это вообще? — токен настоящий, не истёк, не подделан?
  2. Может ли он обратиться к этому endpoint? — у него есть нужная роль?
  3. Может ли он работать именно с этим ресурсом? — это его заказ, или чужой?

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

Три уровня — три вопроса

УровеньВопросЧто проверяет
Gateway / API edgeКто это?Подпись JWT, срок действия, издатель
BFF / Application LayerМожно ли сюда обращаться?Роль пользователя (RBAC)
Domain ServiceМожно ли работать с этим объектом?Владение ресурсом (ABAC)

Gateway — кто стучится в дверь

Первый пропускной пункт — это Gateway (или сам сервис, если Gateway нет). Его задача — ответить на вопрос «кто этот клиент?».

Что Gateway делает:

  • Извлекает токен из заголовка Authorization: Bearer <jwt>.
  • Проверяет подпись токена через JWK Set (публичные ключи IdP).
  • Проверяет срок действия (exp), издателя (iss) и аудиторию (aud).
  • Ограничивает количество запросов (rate limiting).
  • Передаёт identity дальше — через тот же заголовок или через X-User-Id, X-User-Roles.

Если токен невалиден — запрос получает 401 Unauthorized, и никакие внутренние сервисы не вызываются.

Пользователь → POST /orders  +  Bearer <токен>
                     ↓
                  Gateway
                     ↓ (если токен валиден)
               order-service: знает, что пришёл user-42

Что Gateway не делает: он не знает, какие endpoint-ы существуют и какие роли нужны. И тем более не знает бизнес-модель — кому принадлежит заказ №12345.

В Spring Boot Gateway — это Spring Cloud Gateway или Istio с JWT-фильтром. Если внешнего Gateway нет, проверку токена делает сам сервис через oauth2ResourceServer в Spring Security — поведение то же самое.

BFF — есть ли право зайти в эту дверь

Допустим, токен валиден. Теперь второй вопрос: «может ли пользователь с его ролью обращаться к этому конкретному endpoint?».

Это называется RBAC (Role-Based Access Control) — контроль доступа на основе ролей. В Spring это делается через @PreAuthorize прямо на контроллере:

@RestController
@RequestMapping("/admin/orders")
public class AdminOrderController {

    @PostMapping("/{id}/refund")
    @PreAuthorize("hasRole('ADMIN')")
    public Order refund(@PathVariable Long id) {
        return dispatcher.dispatch(new RefundOrderCommand(id));
    }
}

@RestController
@RequestMapping("/orders")
public class OrderController {

    @GetMapping("/{id}")
    @PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
    public OrderResponse get(@PathVariable Long id) {
        return dispatcher.dispatch(new GetOrderByIdQuery(id));
    }
}

Если роль не подходит — Spring Security вернёт 403 Forbidden ещё до того, как запрос дойдёт до бизнес-логики.

Типичное разграничение:

  • POST /admin/* — только ADMIN.
  • GET /orders/*CUSTOMER или ADMIN.
  • POST /orders — только CUSTOMER (клиент создаёт заказы сам).

Что RBAC не проверяет: он не знает, чей именно заказ №12345. Это не его задача.

Domain Service — можно ли работать именно с этим объектом

Третий вопрос — самый тонкий: «этот пользователь имеет право читать или менять именно этот ресурс?».

Роль CUSTOMER есть у всех покупателей. Но покупатель не должен видеть чужие заказы. Это нельзя проверить по роли — нужно загрузить объект и сравнить владельца с текущим пользователем.

Такой подход называется ABAC (Attribute-Based Access Control). Он живёт внутри обработчика бизнес-логики:

@UseCase
@RequiredArgsConstructor
public class GetOrderByIdHandler implements UseCaseHandler<GetOrderByIdQuery, Order> {

    private final OrderRepository orderRepository;

    @Override
    @Transactional(readOnly = true)
    public Order handle(GetOrderByIdQuery query) {
        var order = orderRepository.findById(query.orderId())
            .orElseThrow(() -> new OrderNotFoundException(query.orderId()));

        var currentUserId = SecurityContextHolder.getContext().getAuthentication().getName();
        if (!order.getCustomerId().equals(Long.valueOf(currentUserId)) && !hasAdminRole()) {
            throw new OrderNotFoundException(orderId);   // чужой заказ неотличим от несуществующего
        }
        return order;
    }
}

Логика: загрузили заказ №12345, у него customerId=42, текущий пользователь sub=99 — отказ. Роль CUSTOMER есть, endpoint разрешён, но этот покупатель читает чужой заказ.

Подробнее о реализации ABAC — в статье ABAC: владение ресурсом.

Три уровня на маркетплейсе: один запрос целиком

Проследим один запрос из кейса маркетплейса — продавец меняет цену на своей карточке — и посмотрим, что происходит на каждом уровне.

PATCH /api/seller/cards/8891  {"price": 4990}
Authorization: Bearer <токен продавца>

Gateway. Достаёт токен, проверяет подпись по ключам realm marketplace, срок и издателя. Ничего не знает про карточку 8891 и про то, что такое «продавец». Если токен просрочен — 401, и Catalog Service даже не узнает о запросе.

Catalog Service, слой приложения. Проверяет роль: путь /api/seller/** требует seller. Покупатель с ролью buyer получит 403 здесь — до всякого обращения к базе. Это дёшево: роль уже лежит в разобранном токене.

Catalog Service, доменный слой. Загружает карточку 8891 и сравнивает card.sellerId с sub из токена. Не совпало — 403, и это уже совсем другой отказ: роль правильная, но карточка чужая.

Ключевой момент: третью проверку нельзя поднять на Gateway. Чтобы узнать, чья это карточка, нужно сходить в базу каталога — а Gateway про базы сервисов ничего не знает и знать не должен. Попытка научить его этому превращает Gateway в место, куда стекается бизнес-логика всех сервисов сразу.

Так же раскладываются и остальные пути маркетплейса:

ЗапросGatewayРольВладение / состояние
GET /api/catalog/8891пропускает без токена
POST /api/ordersпроверяет токенbuyer
POST /api/orders/551/cancelпроверяет токенbuyerзаказ мой и ещё не в доставке
POST /api/moderation/cards/8891/approveпроверяет токенmoderator
POST /api/payouts/77/approveпроверяет токенfinanceсумма выше лимита — нужен второй подтверждающий

Витрина каталога открыта без токена намеренно: карточки товаров должны попадать в поисковую выдачу. А действия сотрудников площадки закрываются одной ролью — модератор работает с любой карточкой, ему не нужно ничем владеть.

Почему ABAC нельзя делать на Gateway

Иногда возникает соблазн проверять всё на Gateway — чтобы «отсечь раньше». Но с ABAC это не работает.

Проблема: чтобы Gateway ответил «может ли user-99 читать заказ 12345», ему нужно знать, кому принадлежит заказ. Для этого нужно идти в базу данных или вызывать order-service. То есть Gateway фактически становится ещё одним сервисом, который понимает бизнес-модель.

Что плохого:

  • При изменении модели (например, добавили соавторов заказа) нужно обновлять и order-service, и Gateway.
  • Появляется два источника правды о том, кому принадлежит ресурс.
  • Gateway перегружается логикой, которая ему не принадлежит.

Правило: Gateway делает только аутентификацию. ABAC — только внутри Domain Service, где живёт агрегат.

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

Endpoint без @PreAuthorize. Если забыть аннотацию — Spring Security пропустит запрос с любой ролью. Каждый endpoint должен явно объявлять, кто к нему имеет доступ.

Только RBAC без ABAC для ресурсо-ориентированных endpoint-ов. GET /orders/{id} проверяет роль, но не владельца — любой CUSTOMER читает любой заказ.

JWT-проверка внутри Handler. Обработчик бизнес-логики не должен разбирать токен вручную — это работа OAuth2 Resource Server на уровне Edge. Handler получает уже извлечённые данные из SecurityContextHolder.

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

Коротко

  • Auth — это три разных проверки, не одна: аутентификация, RBAC, ABAC.
  • Gateway проверяет JWT: подпись, срок, издатель. Невалидный токен → 401, дальше запрос не идёт.
  • BFF / Controller проверяет роль через @PreAuthorize. Нет роли → 403.
  • Domain Service проверяет владение конкретным ресурсом. Чужой объект → 403.
  • ABAC нельзя делать на Gateway: Gateway не знает доменную модель.
  • Каждый endpoint должен иметь явную RBAC-аннотацию — без неё дверь открыта всем.

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