JavaRush /Курсы /Spring Security /URL-правила и бизнес-операции

URL-правила и бизнес-операции

Spring Security
19 уровень , 0 лекция
Открыта

1. URL‑правила: путь без смысла

Если упростить до бытовой аналогии, request-level security — это охранник на входе в здание, который смотрит на пропуск и решает “пускать/не пускать”. Это полезно, но охранник не знает, зачем вы идёте внутрь и к какому сейфу. В backend‑мире роль “сейфа” играет сервисный метод: именно там происходит “опасное действие” — публикация контента, блокировка пользователя, смена ролей. И если мы защищаем только вход по URL, мы защищаем маршрут, но не сам “сейф”.

В терминах Spring Security request-level authorization опирается на HttpServletRequest: путь, HTTP‑метод, иногда заголовки, иногда origin и прочие параметры запроса. Но request-level правило не знает, что внутри контроллера вы вызываете reviewService.publish(draftId), и уж точно не знает, что тот же самый publish(...) может быть вызван из другого контроллера, другого endpoint’а или вообще не через HTTP (например, из другого сервиса). Поэтому “закрытый URL” ещё не означает “закрытая бизнес-операция”.

Чтобы было максимально конкретно, возьмём знакомую конфигурацию, где access matrix выражена только через URL‑зоны:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
                // Публичные GET-эндпоинты: доступны всем, включая анонимных пользователей
                .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
                // Редакторская зона: требует конкретного права (authority)
                .requestMatchers("/api/editor/**").hasAuthority("draft:review")
                // Админская зона: отдельное право для управления пользователями
                .requestMatchers("/api/admin/**").hasAuthority("user:manage")
                // Всё остальное: просто требуем аутентификацию (кто угодно, но не аноним)
                .anyRequest().authenticated()
        );

        // Собираем SecurityFilterChain, который будет применяться ко всем HTTP-запросам
        return http.build();
    }
}

На уровне “периметра” всё выглядит аккуратно: публичное чтение — всем, редакторская зона — тем, кто умеет review, админская зона — тем, кто умеет управлять пользователями. Но важно видеть границу этого фрагмента: это осознанно ещё чистый request-level snapshot, где права живут только на уровне входов. Он хорошо разводит зоны по URL, но сам publish(...), lockUser(...) и другие опасные действия ещё не получили собственного замка.

Именно здесь быстро всплывает разница, которую URL‑зона сглаживает: draft:review и draft:publish — не одно и то же, как и user:read и user:manage. На уровне зоны они слишком похожи, а на уровне бизнес‑операции — уже нет.

2. Когда URL‑защиты недостаточно

Первый раз это замечают обычно не на лекции, а на проде (что, честно говоря, слишком дорогая лабораторная работа). Вы добавляете новый endpoint, что-то рефакторите, переносите код между контроллерами — и внезапно оказывается, что запрет на “опасную операцию” был завязан на конкретный URL, а не на саму операцию. И даже без злого умысла появляется “дырка”: не потому что вы плохой человек, а потому что мозг не умеет держать в памяти все возможные входы в систему. Давайте разберём три самых жизненных сценария.

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

import org.springframework.stereotype.Service;

@Service
class ReviewService {

    public void publish(Long draftId) {
        // ВАЖНО: это бизнес-операция (не "кусок контроллера"), её могут вызвать из разных мест
        // TODO: меняем статус DRAFT/SUBMITTED -> PUBLISHED
        // TODO: ставим publishedAt
        // TODO: логируем событие публикации (для аудита), если это требуется по домену
    }
}

Сценарий 1: один сервисный метод вызывают из двух разных контроллеров

Сначала у нас есть «правильный» endpoint, например из editor‑зоны:

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class EditorController {

    private final ReviewService reviewService;

    EditorController(ReviewService reviewService) {
        this.reviewService = reviewService;
    }

    @PostMapping("/api/editor/drafts/{id}/publish")
    void publish(@PathVariable Long id) {
        // Контроллер тонкий: принимает HTTP-запрос и делегирует бизнес-операцию сервису
        reviewService.publish(id);
    }
}

И кажется, что всё хорошо: /api/editor/** защищён, значит publish защищён. А потом (в будущем, или просто “в пятницу вечером”, когда хочется домой) кто-то добавляет endpoint для admin‑зоны, потому что “ну админ же всё может”:

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class AdminDraftController {

    private final ReviewService reviewService;

    AdminDraftController(ReviewService reviewService) {
        this.reviewService = reviewService;
    }

    @PostMapping("/api/admin/drafts/{id}/publish")
    void publishAsAdmin(@PathVariable Long id) {
        // ВНИМАНИЕ: мы всё равно вызываем ту же бизнес-операцию publish(...)
        // но URL-правило на /api/admin/** может быть вообще "про другое" (например, только про user:manage)
        reviewService.publish(id);
    }
}

Если request-level правило на /api/admin/** написано про управление пользователями (user:manage), то с точки зрения “периметра” вы вообще не обязаны задумываться о публикации черновиков. Но бизнес‑операция уже открылась через новый вход. И вам приходится либо расширять URL‑правила (и поддерживать их в голове), либо надеяться, что никто больше не добавит третью дверь в этот же метод.

Сценарий 2: один URL‑паттерн описывает слишком широкую зону

Очень частая история — “сделаем одним махом”:

.requestMatchers("/api/editor/**").authenticated()

Выглядит удобно, пока не появится в editor‑зоне что-то, что должно быть доступно только части пользователей (например, review queue видят все редакторы, а publish/reject — только некоторые). На уровне URL вы начинаете либо дробить зоны и плодить matchers, либо мириться с тем, что “ну раз вошёл — значит можно”.

На старте это нормально — мы не строим энтерпрайз‑ACL. Но важно понять, что чем шире matcher, тем меньше он похож на реальное бизнес‑правило. Это не “плохо”, это просто другое назначение: request-level правила — грубая разводка зон, а не точная модель бизнес‑доступа.

Сценарий 3: бизнес‑операцию вызывают не из контроллера

Это момент, когда у Junior‑разработчика обычно появляется фраза: “Подождите… а так можно было?”

Да, можно. Внутри приложения метод reviewService.publish(...) может быть вызван:

— из другого сервиса (например, вы делаете orchestration flow);

— из обработчика событий;

— из “временного” утилитарного компонента;

— в тестах (что особенно весело: тест проходит, а прод падает в доступах, потому что security сидела только в HTTP‑слое).

Request-level security защитит только тот путь, который начинается с HTTP‑запроса. Всё, что происходит “внутри”, — это уже просто Java‑вызовы.

Можно нарисовать это в виде простой схемы. Она специально без деталей Spring‑внутренностей, чтобы было видно главное:

flowchart TD
    A["HTTP запрос"] --> B["SecurityFilterChain request-level rules"]
    B --> C["Controller"]
    C --> D["Service метод: publish/lockUser/changeRoles"]

    E["Другой Controller"] --> D
    F["Другой Service"] --> D
    G["Любой Spring component"] --> D

Если защита живёт только в точке B, то ветки E/F/G (любые не‑HTTP или “другие HTTP‑входы”) остаются без прямой привязки к правилу, которое вы “мысленно” ассоциировали с publish. И это ровно тот момент, когда в проекте появляется ощущение хрупкости: вроде всё “по правилам”, но почему-то страшно рефакторить.

3. Дублирование правил и копипаста

Одна из самых дорогих ошибок в backend‑разработке — это не “написать неправильно”, а “написать одинаковое в двух местах, но со временем разное”. Security сюда подходит идеально: вы можете честно защитить endpoint в одном контроллере, потом добавить второй endpoint, потом третий — и в какой-то момент правила начинают расходиться. Не потому что вы ленивый, а потому что мозг не система контроля версий.

Почти всегда история начинается невинно: “вынесем одну и ту же операцию в сервис, чтобы не дублировать бизнес‑код”. И это правильно. Но если безопасность остаётся только в URL‑правилах, то получается странная симметрия:

— бизнес‑код мы централизовали в сервисе;

— security‑код не централизовали и размазали по входам.

На практике вы ловите такие эффекты: один endpoint возвращает 403, другой — 200, но оба вызывают один и тот же метод. Или наоборот: вы добавили “новый правильный endpoint”, а старый забыли закрыть — и он продолжает работать. Особенно “весело”, когда старый endpoint не используется фронтендом, и его никто не проверяет руками. Он просто тихо существует и ждёт своего звёздного часа.

Чтобы почувствовать разницу, полезно сравнить две “картины мира” — request-level и service-level. Я специально сделаю это таблицей, потому что человеческому мозгу иногда нужен прямоугольник, а не поток сознания.

Что мы защищаем Где написано правило На что правило может опираться Сколько “входов” покрывает правило
HTTP‑вход (URL, метод) SecurityFilterChain path, HttpMethod, headers Только те входы, которые матчятся в SecurityFilterChain
Бизнес‑операцию (publish/lockUser) рядом с сервисным методом текущая аутентификация и права Любой вызов метода через Spring‑инфраструктуру (подробности — позже сегодня)

Вторая строка — это и есть тот “сдвиг мышления”, ради которого мы вообще пришли к method security. Не чтобы “заменить URL‑rules”, а чтобы добавить второй уровень, который защищает не маршрут, а действие.

4. Контроллер — не место для авторизации

На этом месте обычно возникает естественная мысль: “Окей, URL‑rules не идеальны. Тогда я сделаю проверку внутри контроллера: if (user not editor) throw — и всё.”

Это выглядит как решение, но оно быстро превращается в проблему. Контроллер — это слой про HTTP: параметры запроса, статус‑коды, DTO, сериализация, немного валидации. Как только вы начинаете писать в контроллере полноценную авторизацию, он становится “центром принятия решений”, а сервис превращается в “тупой исполнитель”. Ровно наоборот того, что мы хотим.

Вот пример “контроллерной авторизации”, которая кажется нормальной, но на практике быстро расползается:

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class EditorController {

    private final ReviewService reviewService;

    EditorController(ReviewService reviewService) {
        this.reviewService = reviewService;
    }

    @PostMapping("/api/editor/drafts/{id}/publish")
    void publish(@PathVariable Long id, Authentication auth) {
        // Плохая идея: контроллер начинает "решать", кому что можно,
        // и эта логика расползается по другим входам (другим контроллерам/эндпоинтам)
        if (auth == null || auth.getAuthorities().isEmpty()) {
            // Плохая идея для API: исключение про "состояние приложения" превращается в 500,
            // хотя по смыслу тут должен быть 401/403 (в зависимости от ситуации)
            throw new IllegalStateException("No authorities");
        }

        // Даже если проверку сделать "красиво", вы всё равно дублируете правила по входам
        reviewService.publish(id);
    }
}

Проблема тут не в том, что IllegalStateException “не такой красивый” (хотя и это тоже). Проблема в том, что вы только что смешали responsibilities. Контроллер теперь отвечает не только за вход в систему, но и за модель доступа. И теперь, если появится второй вход в reviewService.publish(...) — вам придётся скопировать эту логику туда же. А потом ещё раз. А потом ещё раз. И вот вы уже строите свой мини‑Spring Security на коленке, хотя у нас в проекте Spring Security уже есть.

Кроме того, контроллерная логика почти всегда приводит к плохой семантике ошибок. У нас весь прошлый блок был про то, чтобы различать 401 и 403, и чтобы ошибки выглядели единообразно. Ручные if‑ы очень легко превращают 403 в 500, или наоборот, и клиент получает сюрпризы.

Поэтому мысль дня звучит немного скучно, но зато спасает нервы: контроллер должен быть тонким, сервис — умным, а правила доступа должны жить рядом с тем, что они защищают. Если защищаем “publish”, то правило должно быть не “вокруг URL”, а “вокруг publish”.

5. Примеры риска: publish и admin‑операции

Нам важно не просто философствовать, а видеть конкретные места проекта, где URL‑защита почти неизбежно начинает “не совпадать” с бизнес‑операциями. В нашем Secure Content Platform API есть два характерных семейства действий: редакторская модерация контента и административные операции над аккаунтами. Они хороши тем, что у них высокая цена ошибки, и поэтому их проще воспринимать как “опасные” операции, которые должны быть защищены максимально очевидно.

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

import org.springframework.stereotype.Service;

@Service
class ReviewService {
    public void publish(Long draftId) {
        // Опасная операция: публикуем контент (это меняет состояние системы)
        // Здесь особенно важно, чтобы правило доступа было привязано к методу, а не только к URL
    }
}
import org.springframework.stereotype.Service;

@Service
class AdminUserService {
    public void lockUser(Long userId) {
        // Опасная операция: блокируем аккаунт пользователя
        // Это тоже "capability" системы, и для неё хочется явного правила рядом с методом
    }
}

На уровне URL‑правил эти операции обычно живут в разных зонах:

— publish/reject: /api/editor/**

— lock/enable/change roles: /api/admin/**

И кажется, что всё ровно. Но вот где появляется тонкость.

Тонкость №1: зона ≠ операция. /api/admin/** в нашем проекте про пользователей. Если вы случайно добавили туда контентную операцию (как в примере с AdminDraftController), то правило на /api/admin/** вообще не отражает смысл publish. Оно отражает смысл “управление пользователями”. Это разные права (user:manage vs draft:publish). В итоге или вы открыли publish тем, кому он не должен быть открыт, или наоборот — “сломали” publish для тех, кому он нужен.

Тонкость №2: разные endpoint’ы могут вести к одному действию. Вы можете сделать один endpoint, который публикует черновик, второй — который “публикует и сразу пингует подписчиков” (условно), третий — который “публикует из очереди”. Все они могут вызывать один и тот же сервисный метод. И вам хочется, чтобы правило было одно: “публиковать может тот, у кого есть право publish”. И чем раньше вы начнёте мыслить так, тем меньше вы будете бояться добавлять новые endpoint’ы.

Тонкость №3: сервисные методы — это реальные бизнес‑капабилити. Если в коде есть метод lockUser(...), то это уже capability системы: “умеем блокировать аккаунты”. И очень хочется, чтобы рядом с этим методом стояло максимально очевидное правило “кто имеет право это делать”. Не в README, не в голове, не в контроллере, не в SecurityConfig где-то внизу — а рядом с самим действием.

Здесь уже видно, какое правило нам нужно: доступ должен висеть не на новой двери в SecurityConfig, а прямо на самой операции publish, lockUser или changeRoles. Для этого и появляется отдельный слой — method security, который умеет ставить замок на сервисный метод, а не только на URL.

6. Два уровня защиты: URL и методы

Очень важно не сделать неправильный вывод. URL‑rules не становятся “плохими” и не требуют удаления только потому, что у нас появился второй уровень защиты. Наоборот: они остаются первым рубежом обороны, и именно они быстро отсекают анонимные запросы и разграничивают зоны приложения. Если вы уберёте request-level rules и оставите только method security, вы получите странное и менее предсказуемое приложение: контроллеры будут вызываться чаще, обработка ошибок может стать менее очевидной, а конфигурация потеряет “карту входов”.

Правильная картина такая: request-level правила отвечают за то, чтобы запрос вообще попал в нужную зону и не прошёл “в открытую дверь”. А service-level правила отвечают за то, чтобы даже внутри зоны нельзя было сделать то, на что нет права. Это и называется “defense in depth”, но без пафоса: просто два замка вместо одного, потому что люди иногда забывают закрыть один.

Если держать это в голове, у вас появляется полезная привычка проектирования. Когда вы добавляете новый endpoint, вы задаёте себе два вопроса. Сначала “в какую зону он попадает, и что скажет SecurityFilterChain?”, а потом “какую бизнес‑операцию он вызывает, и есть ли у этой операции локальная защита?”. И вот второй вопрос — это то, чего нам пока не хватало.

7. Типичные ошибки при защите операций

Ошибка №1: считать, что “закрыли /api/editor/** — значит publish защищён навсегда”.
Это мышление работает только пока вы уверены, что publish вызывается ровно из одного контроллера и никогда не появится второй вход. В реальном проекте новые входы появляются постоянно: новые endpoint’ы, новые сценарии, временные административные ручки, рефакторинг. Если защита живёт только в SecurityFilterChain, вы начинаете защищать маршруты, а не действия — и безопасность становится хрупкой.

Ошибка №2: переносить авторизацию в контроллер через if‑ы, потому что “так быстрее”.
Контроллерная авторизация почти всегда приводит к размазыванию правил по проекту, к дублированию и к плохой семантике ошибок. Плюс это затрудняет поддержку: вы меняете бизнес‑правило — и потом ищете по всему коду, в каком контроллере оно было “вшито”. В результате контроллеры толстеют, сервисы худеют, а проект превращается в лабиринт, где право доступа зависит от того, каким путём вы зашли.

Ошибка №3: защищать зоны слишком широкими matcher’ами и пытаться выразить точные бизнес‑правила только через URL.
Соблазн “одной строкой закрыть всё” понятен, особенно когда хочется быстро получить working security baseline. Но чем шире URL‑правило, тем меньше оно похоже на бизнес‑право. В какой-то момент вы начинаете либо бесконечно дробить matchers, либо мириться с тем, что “в зоне можно всё”. Оба варианта плохо масштабируются по мере роста проекта и по мере появления authorities.

Ошибка №4: путать “что удобно конфигурировать” с “что правильно защищать”.
URL‑конфигурация действительно удобна: она видна в одном месте и читается сверху вниз. Но бизнес‑операции живут не в конфиге, а в сервисах. Если операция опасная и важная (publish, lock user, change roles), то желание видеть рядом с ней явное правило доступа — это не прихоть, а попытка сделать систему понятной и устойчивой к изменениям. Когда защита размещена рядом с действием, проект становится меньше похож на карточный домик и больше — на нормальный backend, который можно развивать без страха.

1
Задача
Spring Security, 19 уровень, 0 лекция
Недоступна
Одна service-операция за двумя URL-зонами
Одна service-операция за двумя URL-зонами
1
Задача
Spring Security, 19 уровень, 0 лекция
Недоступна
Одинаковый сервис под двумя наборами request-rules
Одинаковый сервис под двумя наборами request-rules
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ