Когда пользователь отправляет запрос, приложение должно ответить на три разных вопроса:
- Кто это вообще? — токен настоящий, не истёк, не подделан?
- Может ли он обратиться к этому endpoint? — у него есть нужная роль?
- Может ли он работать именно с этим ресурсом? — это его заказ, или чужой?
Это три разных вопроса, и каждый из них задаётся на своём уровне. Если смешать их в одном месте — получается либо дублирование с риском расхождений, либо дыры: один 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-аннотацию — без неё дверь открыта всем.
Что почитать дальше
- ABAC: владение ресурсом — как реализовать проверку владения через
@access-бин или внутри Handler. - JWT validation — стандартный Spring Security flow для проверки токенов.
- RBAC: маппинг ролей — как настроить
@PreAuthorizeи каталог ролей. - Service-to-service — как сервисы аутентифицируют друг друга.
- Кейс: маркетплейс — сквозной пример сайта: пути, роли и проверки владения из этой статьи разобраны на нём.