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

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

Один и тот же запрос POST /orders/12345/cancel проходит два слоя проверки, и от того, что делает второй слой, зависит не только результат, но и то, что можно узнать перебором чужих номеров.

POST /orders/{id}/cancel — что решает каждый слой customer-2 отменяет заказ 12345заказ принадлежит customer-1 admin-7 отменяет заказ 12345роль admin, заказ чужой RBAC: hasAnyRole('customer','admin') роль подходит — слой пройден во всех четырёх случаях ABAC: проверки нетid из пути берут как естьвладельца не сравнивали ни разу ABAC: @access.canEditOrder(12345)владелец заказа — customer-1, пришёл customer-2 ABAC: hasRole('admin') — обход проверкивладелец заказа — customer-1, пришёл admin-7 не совпало → отвечаем 403 Forbidden не совпало → отвечаем 404 Not Found не совпало, но роль admin — пишем в журнал id 12345 — чужой заказ id 99999 — такого нет 404 Not Found заказа нет в базе 200 — заказ отменёнвладение не проверяли 403 Forbiddenответ выдаёт: заказ есть 404 Not Foundответ не выдаёт ничего 200 — отменён + журналadmin-7 · cancel-order · 12345 роль пропустила, владельца не сравнили — чужой заказ отменён (IDOR) 403 и 404 различаются — перебором id находят реальные заказы оба ответа 404 — чужой заказ неотличим от несуществующего admin проходит мимо ABAC, но его действие остаётся в журнале

RBAC пропускает всех — роль customer есть и у чужака. Работает второй слой, и то, каким кодом он отказывает, решает, можно ли перебором нащупать чужие заказы.

Обязательно

Почему одной роли недостаточно

Представьте интернет-магазин. Пользователь с ролью customer может вызвать GET /orders/{id} — он же покупатель, ему разрешено просматривать заказы. Но что мешает ему подставить чужой id и прочитать заказ другого человека? Только то, что в коде есть проверка: «этот заказ принадлежит тебе?»

Без такой проверки любой holder токена с правильной ролью получит доступ к чужим данным. В мире безопасности это называется IDOR — Insecure Direct Object Reference, доступ к объекту по идентификатору без проверки прав.

RBAC (Role-Based Access Control) отвечает на вопрос «какую операцию разрешено выполнять этой роли». ABAC (Attribute-Based Access Control) добавляет второй вопрос: «а разрешено ли этой конкретной роли работать именно с этим конкретным объектом».

Оба слоя работают вместе: сначала RBAC («ты покупатель, операция разрешена»), потом ABAC («этот заказ действительно твой?»).

Проверка до входа в обработчик: access-бин и @PreAuthorize

Простой и прозрачный способ: создать Spring-компонент с методами проверки и вызывать их прямо в аннотации @PreAuthorize.

@Component("access")
@RequiredArgsConstructor
public class AccessChecker {

    private final OrderRepository orderRepository;

    public boolean canEditOrder(Long orderId, String subject) {
        // subject — это claim sub из токена: у Keycloak там UUID,
        // и Long.valueOf на нём упадёт
        var userId = UUID.fromString(subject);
        return orderRepository.findById(orderId)
            .map(order -> order.getCustomerId().equals(userId))
            .orElse(false);
    }

    public boolean canViewOrder(Long orderId, String subject) {
        return canEditOrder(orderId, subject);
    }
}

Этот бин называется access (через @Component("access")), поэтому в аннотации можно ссылаться на него как @access:

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

    @PostMapping("/{id}/cancel")
    @PreAuthorize("hasAnyRole('customer', 'admin')"
        + " and (hasRole('admin') or @access.canEditOrder(#id, authentication.name))")
    public OrderResponse cancel(@PathVariable Long id) {
        return dispatcher.dispatch(new CancelOrderCommand(id));
    }
}

Строка hasRole('admin') or @access.canEditOrder(...) означает: администратор проходит без проверки владения, остальные — только если заказ их.

Этот способ хорошо работает, когда проверка несложная: сравнить идентификатор владельца в базе с идентификатором из токена. Никакой бизнес-логики — только сравнение.

Проверка рядом с блокировкой: внутри обработчика команды

Когда операция сложнее — например, нужно сначала загрузить объект с блокировкой, затем проверить его состояние и только потом владение — логичнее вынести проверку в обработчик команды:

@UseCase
@RequiredArgsConstructor
public class CancelOrderHandler implements UseCaseHandler<CancelOrderCommand, Order> {

    private final OrderRepository orderRepository;
    private final AuthenticatedUserProvider userProvider;

    @Override
    @Transactional
    public Order handle(CancelOrderCommand command) {
        var order = orderRepository.findById(command.orderId(), SelectMode.FOR_UPDATE)
            .orElseThrow(() -> new OrderNotFoundException(command.orderId()));

        var user = userProvider.current();
        if (!user.isAdmin() && !order.getCustomerId().equals(user.id())) {
            throw new OrderNotFoundException(command.orderId());   // именно 404 — см. пояснение ниже
        }

        if (!order.canCancel()) {
            throw new OrderCannotBeCancelledException(order.id(), order.status());
        }

        order.cancel();
        return orderRepository.save(order);
    }
}

Обратите внимание: объект загружается с FOR UPDATE — это важно для операций записи, чтобы не было гонки между проверкой и изменением. Если бы объект сначала загружал access-бин, а потом обработчик загружал его повторно с блокировкой, вышло бы два запроса вместо одного.

access-бин @PreAuthorize SELECT владельца транзакция SELECT FOR UPDATE обработчик транзакция SELECT FOR UPDATE сверка владельца order.cancel()

Сравните, где стоит сверка владельца: у access-бина она читает заказ до транзакции, и обработчику приходится читать тот же заказ второй раз, уже под блокировкой.

И ещё одна деталь, из-за которой два способа легко рассогласовать: user.id() здесь — это тот же UUID из claim sub, только разобранный один раз внутри AuthenticatedUserProvider, а не в каждом месте проверки. Тип владельца должен совпадать по всему сервису: если в базе customer_id — это uuid, то и order.getCustomerId(), и user.id() — UUID. Половина ошибок в этой теме — попытка сравнить UUID со строкой или разобрать sub как число.

Коллекции: фильтр вместо проверки

Всё описанное выше работает с одним объектом: загрузили, сравнили владельца, ответили или отказали. Со списками этот приём не переносится, а именно списки и составляют большую часть ручек: «мои заказы», «мои карточки», «мои уведомления».

Наивный вариант выглядит безобидно:

// плохо — прочитали всё, отфильтровали в памяти
List<Order> all = orderRepository.findAll();
return all.stream()
        .filter(o -> o.customerId().equals(currentUserId))
        .toList();

Утечки в ответе нет, а проблем три. Чужие строки всё равно прочитаны из базы: они попали в план запроса, в статистику, в журнал медленных запросов. Постраничная выдача ломается — LIMIT 20 по всей таблице и фильтр после него дают двадцать строк, из которых своих может не оказаться ни одной. И фильтр легко потерять: следующий разработчик добавит сортировку, перепишет метод и уберёт «лишний» stream.

Правильный вариант переносит владение в сам запрос:

// хорошо — чужие строки не читаются вовсе
Page<Order> mine = orderRepository.findByCustomerId(currentUserId, pageable);

Отсюда несколько следствий, которые легко упустить. Подсчёты и агрегаты фильтруют так же: count, суммы, экспорт в файл — это тоже выборки, и владелец в них обязателен. Поиск с произвольными условиями от клиента (сортировка по имени колонки, фильтры из запроса) владельца не отменяет: условия клиента добавляются к обязательному WHERE customer_id = ?, а не заменяют его. И @PostFilter из Spring Security для списков — плохая замена: он отсекает уже прочитанное, то есть повторяет фильтр в памяти со всеми его минусами.

Отдельно — откуда берётся currentUserId. Только из токена (SecurityContextHolder или Authentication в методе). Параметр ?customerId= в адресе ручки «мои заказы» не нужен вовсе: он либо дублирует токен, либо позволяет подставить чужой.

Чужой ресурс — 404 или 403

В коде выше отказ по владению — это OrderNotFoundException, то есть 404, а не 403. Выглядит странно: заказ-то существует. В этом и смысл.

403 честно говорит «объект есть, но не ваш» — и этим выдаёт, что объект с таким номером существует. Если номера идут подряд, за ночь перебором собирается список чужих заказов: на реальные приходит 403, на выдуманные — 404, разница видна сразу. Поэтому там, где само существование объекта — не публичная информация (заказы, документы, переписка), на чужой ресурс отвечают ровно то же, что и на несуществующий: 404.

Но правило работает не везде. Если существование объекта и так открыто всем — карточка товара в общедоступном каталоге, профиль автора, публичный пост, — прятать нечего: перебором номеров ничего нового не узнать. Там 403 честнее и понятнее клиенту: «объект есть, просто менять его можешь не ты». Итого: 404 — когда скрываем сам факт существования, 403 — когда факт и так открыт, а закрыто только действие.

отказ 403 50 000 запросов 1 200 раз 403 1 200 номеров есть отказ 404 50 000 запросов 50 000 раз 404 список не собрался

Смотрите на правый столбец: при отказе 403 перебор возвращает список существующих заказов, при 404 ответы неотличимы и собирать нечего.

Когда владельцев несколько

Сравнение order.customerId == currentUserId описывает простейший случай: один объект, один хозяин. Модель обычно оказывается сложнее, и переделка на ходу — обычное дело.

Совладелец. У карточки товара два менеджера, у документа — автор и редактор. Как только владельцев больше одного, equals превращается в проверку вхождения: отдельная таблица связей card_id — user_id — роль в объекте и метод isEditor(cardId, userId) рядом с агрегатом. Класть второго владельца в ту же строку («поле coOwnerId») — тупик: третьего уже не добавить.

Организация вместо человека. У продавца-компании заказы принадлежат не сотруднику, а организации, и сотрудники приходят и уходят. Тогда сравнивают не идентификаторы людей, а принадлежность к организации: order.sellerOrgId против orgId из токена. Это дешевле и переживает смену сотрудников, но требует, чтобы организация лежала в токене, а не подбиралась запросом на каждый вызов.

Делегирование. «Дайте бухгалтеру видеть мои счета», «поддержка действует от имени клиента с его согласия». Здесь появляется третье лицо, и проверка владения становится проверкой права, выданного на время: запись «кто, кому, на что, до какого числа», и она же — источник для журнала. Постоянную роль для такого случая не заводят: роль не истекает.

Разница в коде невелика — проверка всё равно остаётся в одном месте, в методах компонента доступа или в обработчике. Ради этого место и одно: когда у заказа появится совладелец, правка будет одна, а не по всем контроллерам, которые работают с заказом.

Состояние объекта — тоже атрибут

Владение отвечает на вопрос «чей объект», но этого мало: заказ может быть вашим и всё равно не подлежать отмене, потому что курьер уже в пути. Такие правила — тоже ABAC, только атрибут здесь не владелец, а статус.

public boolean canCancel(Long orderId, String userId) {
    Order order = orderRepository.findByIdAndCustomerId(orderId, userId)
            .orElseThrow(OrderNotFoundException::new);
    return order.status() == NEW || order.status() == PAID;
}

У такой проверки есть особенность, которой нет у владения: она устаревает за время выполнения запроса. Владелец заказа не меняется, а статус меняется каждую минуту. Проверка «можно отменить» в @PreAuthorize, то есть до входа в обработчик, читает статус в одной транзакции, а меняет его обработчик в другой: между ними заказ успевает уехать в доставку, и отмена пройдёт по устаревшему решению. Поэтому правила по состоянию проверяют внутри обработчика, рядом с блокировкой строки, а не до него — это ровно та ситуация, ради которой существует второй способ из разделов выше.

И код ответа тут другой. Чужой заказ — это 404 или 403, а свой заказ в неподходящем статусе — 409 Conflict: запрос понятен, прав достаточно, но состояние объекта не позволяет. Смешивать эти два случая в одном ответе не стоит, клиент по ним показывает разные экраны.

Когда какой способ выбрать

Оба способа правильные. Выбор зависит от ситуации:

  • Access-бин подходит, когда проверка простая — сравнить один идентификатор. Хорошо для операций чтения и простых операций записи, где не нужна блокировка.
  • Обработчик подходит, когда рядом с проверкой владения есть бизнес-логика, проверка состояния объекта или нужна блокировка при загрузке.

Главное — не использовать оба способа одновременно для одной операции. Если проверка есть и в @PreAuthorize, и в обработчике, они могут разойтись: изменили в одном месте, забыли в другом.

Где хранить логику проверки

Частая ошибка — писать проверку прямо в контроллере:

// Так не надо
@PostMapping("/{id}/cancel")
@PreAuthorize("hasRole('customer')")
public OrderResponse cancel(@PathVariable Long id, Authentication auth) {
    var order = orderRepository.findById(id).orElseThrow();
    if (!order.getCustomerId().toString().equals(auth.getName())) {
        throw new ResponseStatusException(HttpStatus.FORBIDDEN);
    }
    return dispatcher.dispatch(new CancelOrderCommand(id));
}

Проблема: такая проверка появится в каждом контроллере, который работает с заказом — в методе отмены, в методе редактирования, в методе просмотра. Когда модель изменится (например, у заказа появится совладелец), придётся обновлять несколько мест и не забыть ни одно.

Правильный подход — собрать всю логику проверки для одного типа ресурса в одном месте: либо в методах access-бина, либо в обработчике. Тогда изменение модели — одно место, одна правка.

Администратор: доступ без проверки, но с журналом

Администратор должен иметь возможность работать с любым ресурсом — для поддержки, соблюдения регламентов, расследования инцидентов. Поэтому ABAC-проверку для него обходят.

Но каждое такое действие нужно фиксировать в журнале: кто, что, с чьим ресурсом, когда:

@UseCase
@RequiredArgsConstructor
public class CancelOrderHandler implements UseCaseHandler<CancelOrderCommand, Order> {

    private final OrderRepository orderRepository;
    private final AuthenticatedUserProvider userProvider;
    private final AdminAuditLogRepository auditLog;   // третья зависимость — сам журнал

    @Override
    @Transactional
    public Order handle(CancelOrderCommand command) {
        var order = orderRepository.findById(command.orderId(), SelectMode.FOR_UPDATE)
            .orElseThrow(() -> new OrderNotFoundException(command.orderId()));

        var user = userProvider.current();
        if (!user.isAdmin() && !order.getCustomerId().equals(user.id())) {
            throw new OrderNotFoundException(command.orderId());   // именно 404 — см. пояснение выше
        }

        var previousStatus = order.status();   // снимаем ДО отмены: после cancel() его уже не спросить
        order.cancel();
        var saved = orderRepository.save(order);

        if (user.isAdmin()) {
            auditLog.record(AdminAction.builder()
                .actorId(user.id())
                .action("cancel-order")
                .resourceType("Order")
                .resourceId(order.id())
                .occurredAt(Instant.now())
                .metadata(Map.of("previousStatus", previousStatus.name()))
                .build());
        }
        return saved;
    }
}

Администратор отменил чужой заказ — запись в журнале говорит «admin-7 отменил order-12345 в такое-то время». Это нужно и для аудита службы безопасности, и для разбора инцидентов, и для соблюдения регуляторных требований.

Две вещи, которые здесь легко упустить. Первая: статус «до» снимают в переменную перед order.cancel(). Просить его у объекта после отмены нечем — заказ уже в новом статусе, а метода «а что было раньше» у модели нет и заводить его ради журнала не стоит. Вторая: правило «чужой заказ = несуществующий» для администратора не работает, и это осознанно. Чужой заказ он откроет и получит 200, а на несуществующий номер — 404, потому что заказ просто не загрузится из базы. Администратору разница нужна: без неё он не разберёт ни одну жалобу. Ценой того, что перебором номеров он может узнать, какие заказы существуют, — но у него и так есть доступ ко всем, а каждое его действие остаётся в журнале.

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

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

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

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

Заметность. Обычный пользователь не открывает сто чужих карточек в час. Счётчик административных обращений к чужим данным и сигнал на всплеск ловят злоупотребление, пока оно ещё не стало выгрузкой всей базы.

Тест, который ловит IDOR

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

@Test
void чужойЗаказНеНайден() throws Exception {
    mockMvc.perform(get("/api/orders/{id}", ORDER_OF_ANOTHER_CUSTOMER)
                    .with(jwt().jwt(j -> j.subject(OTHER_CUSTOMER))))
            .andExpect(status().isNotFound());
}

Правило: на каждую ручку, которая принимает идентификатор ресурса, есть такой тест — на чтение, на изменение, на удаление. Три строки на ручку, и они закрывают самую частую уязвимость в списке OWASP: обращение по чужому идентификатору. Забыть новый тест сложнее, чем забыть проверку, потому что ручка без теста не проходит ревью.

Полезны и две проверки рядом. Свой заказ по правильному идентификатору отвечает 200 — иначе тест зелёный на сломанной выборке, которая не находит вообще ничего. И список: под токеном одного покупателя в GET /orders не приходит ни одной строки другого.

Дополнительно: при первом чтении можно пропустить

Глубже: перебор идентификаторов: 404, UUID и ограничение частотырасширенное

Раздел про 404 для чужого ресурса закрывает утечку факта существования. Он не закрывает перебор: клиент с токеном обычного покупателя может за ночь перебрать /orders/1 … /orders/1000000 и по ответам (404 быстро, 404 после чтения из базы, изредка 200 на свой) выяснить число заказов, их темп и найти ошибку в проверке, если она где-то есть.

Три меры, по возрастанию цены. Непредсказуемые идентификаторы. Последовательный id из базы наружу не отдают: UUID версии 4 или ULID в пути делает перебор бессмысленным, потому что угадать следующий нельзя. Внутри база продолжает жить с целым ключом, а наружный идентификатор это отдельная колонка с уникальным индексом. Это ещё и закрывает утечку бизнес-показателей: по номеру заказа «10432» конкурент видит ваши объёмы.

Лимит на клиента и сигнал на долю 404. Обычный пользователь не получает сто 404 в минуту. Ограничитель частоты по идентификатору пользователя (не по адресу, за NAT их сотни) с отдельным, более жёстким порогом на ответы 404 и 403 останавливает перебор, а метрика «доля 404 по клиенту» поднимает тревогу раньше, чем перебор что-то найдёт. Где живёт счётчик и что он делает при отказе хранилища, разбирает статья про ограничение частоты в разделе REST.

Одинаковое время ответа. Если чужой заказ отвечает 404 за 2 мс (отсеян до базы), а несуществующий за 20 мс (сходили в базу), разница в задержке выдаёт то, что скрыл статус. Запрос по владельцу, findByIdAndCustomerId, делает оба случая одинаковыми: одна поездка в базу, один ответ.

Тот же перебор бывает на входе: «пользователь с таким email не найден» против «неверный пароль» позволяет собрать список клиентов. Ответ на вход, восстановление пароля и регистрацию делают одинаковым, «если такой адрес есть, мы отправили письмо», и с тем же лимитом.

Глубже: мультиарендность: tenant_id это не рольрасширенное

Самый частый случай ABAC на практике не «владелец заказа», а «арендатор»: одна установка обслуживает много компаний, и данные одной не должны быть видны другой ни при каких обстоятельствах. Новичок решает это ролью «менеджер компании X», и это не работает: роль отвечает на вопрос «что можно делать», а арендатор на вопрос «какие строки вообще существуют для этого запроса». Ролей десятки, арендаторов тысячи, и заводить роль на каждого нельзя.

Правильная схема состоит из трёх частей. Арендатор берётся из токена, а не из запроса: claim tenant_id кладёт сервер аутентификации при входе, и сервис доверяет только ему. Параметр ?tenantId= или заголовок X-Tenant от клиента это приглашение прочитать чужие данные, о чём говорит статья про границу. Каждый запрос к данным фильтруется по арендатору, и не рукой в каждом методе репозитория, потому что один пропущенный WHERE tenant_id = ? отдаёт всё. Hibernate 6 умеет это сам: колонка помечается @TenantId, а CurrentTenantIdentifierResolver отдаёт значение из контекста запроса, и фильтр добавляется ко всем запросам сущности автоматически. Ещё надёжнее на уровне базы: политика безопасности строк в PostgreSQL, CREATE POLICY ... USING (tenant_id = current_setting('app.tenant_id')), и SET app.tenant_id в начале транзакции; тогда даже нативный запрос и забытый фильтр не отдадут чужого. Идентификатор арендатора входит в ключ всего, что кэшируется, индексируется и складывается в очередь: ключ кэша без него отдаёт одному арендатору данные другого.

Проверяют это тестом, который обязателен: под токеном арендатора A запросить объект арендатора B по прямому идентификатору и получить 404, для каждого ресурса. Административный доступ через арендаторов, для поддержки, оформляют как в разделе про администратора: отдельная роль, явный выбор арендатора и запись в журнал, а не «суперпользователь без фильтра».

Когда арендаторов мало и они крупные, вместо общей таблицы с колонкой дают каждому свою схему или базу; изоляция сильнее, миграции и эксплуатация дороже. Колонка с фильтром это выбор по умолчанию для сотен и тысяч небольших арендаторов.

Коротко

  • RBAC отвечает «роль разрешена», ABAC добавляет «этот объект принадлежит тебе». Без ABAC любой держатель токена с нужной ролью читает и меняет чужие данные.
  • Два правильных места для проверки: access-бин (@Component("access") + @PreAuthorize) для простых случаев и обработчик команды для операций с бизнес-логикой или блокировкой.
  • Оба способа не смешивают для одной операции — иначе проверки разойдутся при изменении модели.
  • Проверку не пишут в контроллере — она дублируется по всем методам и трудно поддерживается.
  • Администратор обходит ABAC, но каждое его действие над чужим ресурсом фиксируется в журнале, а сам обход ограничен обязательной причиной, временным доступом и вторым человеком на деньгах.
  • Перебор идентификаторов закрывают UUID наружу, лимит на клиента с отдельным порогом на 404 и одинаковое время ответа через запрос по владельцу; ответы на вход и восстановление одинаковы для существующих и нет.
  • Арендатор это не роль: tenant_id только из токена, фильтр ко всем запросам через @TenantId или политику строк PostgreSQL, идентификатор в ключах кэша, тест «чужое отвечает 404» на каждый ресурс.
  • Списки закрывают фильтром в запросе (findByCustomerId), а не проверкой после выборки: иначе ломается постраничная выдача и чужие строки всё равно читаются.
  • Владельцев может быть несколько: совладелец — таблица связей, организация — идентификатор организации из токена, делегирование — право с датой окончания вместо роли.
  • Состояние объекта — такой же атрибут, как владелец, но оно устаревает: проверять внутри обработчика и отвечать 409, а не 403.

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