1. Проблема: один и тот же URL — разные действия
Если смотреть на REST API «с высоты птичьего полёта», легко впасть в иллюзию: раз путь начинается с "/api/admin", значит «это админка», раз путь начинается с "/api/drafts", значит «это черновики». Но дьявол, как обычно, живёт не просто в деталях — он там прописан по ВНЖ и платит коммуналку.
Грубая карта зон у нас уже есть: public, authenticated, editor/admin. Но как только внутри одной зоны рядом оказываются чтение, изменение и публикация, одних префиксов пути уже мало. Теперь нужен более точный язык: method + path pattern + требование.
Одна и та же зона пути почти всегда содержит разные операции. GET — обычно чтение, POST — создание, PATCH — изменение, DELETE — удаление. И если мы дадим всем этим методам один общий допуск, мы либо перетянем гайки и сделаем API неудобным, либо отпустим гайки и внезапно «впустим не того человека не туда».
Давайте на живом примере.
Представьте админскую часть проекта:
- GET /api/admin/users — посмотреть список пользователей (право “читать”).
- PATCH /api/admin/users/{id}/roles — изменить роли (право “управлять”).
Если мы скажем: «всё /api/admin/** требует user:manage», то чтение тоже станет “управлением”. Вроде безопасно, но вы сами себе создадите лишние запреты: роль/право для чтения (user:read) станет бесполезной. А если наоборот — повесим на /api/admin/** только user:read, то любой «читатель» внезапно сможет менять роли. И это уже не «ой», а «ой-ой-ой».
Так что нам нужен язык, который говорит не только «куда пришли», но и «что хотят сделать».
2. Ментальная модель: method + path + требование
Чтобы не превращать security-конфиг в набор заклинаний из туториалов, держим простую картинку: для Spring Security входящий запрос — это минимум method + path (и ещё куча деталей, но сегодня нам хватит этого). На уровне request-level authorization мы строим правила, которые отвечают: «разрешаем ли мы вообще такой запрос пропустить дальше в приложение».
Можно думать об этом как о “маске” на входе в здание. Путь говорит, к какой двери подошли. Метод говорит, что человек делает: читает вывеску, пытается открыть дверь, переставляет замок или выносит дверь вместе с коробкой передач. Примерно одинаково «в здание пришли», но действия разные, и охрана реагирует по-разному.
Удобно записать это почти формулой:
# Правило авторизации на уровне запроса (это именно "турникет" на входе)
Request-level rule = (Method, PathPattern) -> permit | authenticated | hasAuthority | hasRole | deny
# Важно: здесь мы проверяем только форму запроса (метод + путь),
# а не бизнес-смысл вроде "это точно твой черновик".
И ещё одна важная мысль, которая сильно экономит нервы: request-level authorization не обязан быть идеальным и «понимать бизнес». Его задача — не пустить явную ерунду и сразу отделить публичное от приватного и привилегированного. А бизнес-ограничения (типа «это твой черновик или чужой») сюда обычно не помещаются без боли и странных костылей.
Чтобы это стало нагляднее, вот маленькая табличка на одном ресурсе:
| Ресурс | Метод | Смысл операции (по REST) | Пример требования на request-level |
|---|---|---|---|
| /api/admin/users/** | GET | чтение | hasAuthority("user:read") |
| /api/admin/users/** | PATCH | изменение | hasAuthority("user:manage") |
| /api/public/articles/** | GET | публичное чтение | permitAll() |
| /api/public/articles/** | POST | создать статью (и это подозрительно) | denyAll() или вообще не иметь такого endpoint |
Мы как раз учимся мыслить вот этой связкой: один префикс пути — разные методы — разные требования.
3. Path pattern: * и /**
Когда у вас один-два endpoint’а, можно писать точные строки. Но как только появляются «семейства» URL (например, /api/editor/drafts/{id}/publish для любых id), вы быстро поймёте, что копировать правила на каждый возможный путь — это занятие, достойное наказания за очень специфические грехи.
Для этого и существуют path patterns — шаблоны пути. В Spring Security чаще всего вы встретите “ант-стиль” (Ant-style), где самые популярные символы такие: * для одного сегмента, /** для целого поддерева. Это не regex, то есть мыслить в духе .* и \d+ здесь не нужно (и лучше не надо, так безопаснее для психики).
Давайте зафиксируем в одной таблице то, что реально пригождается каждый день:
| Pattern | Что означает | Пример совпадения | Пример НЕ совпадения |
|---|---|---|---|
| /api/admin/** | всё внутри /api/admin | /api/admin/users/42/roles | /api/administrator/users |
| /api/editor/drafts/*/publish | один сегмент вместо * | /api/editor/drafts/12/publish | /api/editor/drafts/12/extra/publish |
| /api/public/articles/** | всё под веткой статей | /api/public/articles/spring-security | /api/public/article/spring-security |
Обратите внимание на одну тонкость: * не «любой кусок строки вообще», а «любой кусок внутри одного сегмента пути». Сегменты разделяются /. Это удобнее, чем кажется, потому что очень похоже на «один path variable» в контроллере.
На практике это выглядит так: один matcher покрывает весь класс однотипных URL, и вы получаете правила, которые читаются как карта API, а не как список случайных строк.
Небольшой пример в конфигурации (пока без контекста всего файла, чисто чтобы увидеть паттерн):
import org.springframework.http.HttpMethod;
// внутри authorizeHttpRequests(auth -> auth ...)
// POST + конкретный шаблон пути: это отдельная "операция" в API (publish)
auth.requestMatchers(HttpMethod.POST, "/api/editor/drafts/*/publish")
.hasAuthority("draft:publish"); // Требуем конкретное право на публикацию
Паттерн * здесь «на месте {id}». Мы не проверяем, что там число. Нам на request-level обычно всё равно: если человек пришёл на “publish конкретного черновика”, это уже достаточно узнаваемая операция.
4. Метод-специфичные requestMatchers: разделяем чтение и изменение
После того как мы освоили path patterns, следующим “усилителем точности” становится привязка к HTTP method. И вот тут внезапно начинается настоящая польза от REST-стиля: если вы в API действительно используете методы по назначению, то security-конфигурация становится почти «самодокументируемой».
Самый частый сценарий: у вас есть один префикс, но разные методы. И вы хотите: чтение — одно право, изменение — другое. В учебном проекте это особенно красиво видно на админке.
Например, чтение пользователей (user:read) и управление пользователями (user:manage) — разные вещи. Давайте выразим это прямо:
import org.springframework.http.HttpMethod;
// внутри authorizeHttpRequests(auth -> auth ...)
// 1) Чтение пользователей — отдельное право
auth.requestMatchers(HttpMethod.GET, "/api/admin/users", "/api/admin/users/**")
.hasAuthority("user:read"); // Разрешаем только тем, у кого есть право чтения
// 2) Любые изменения пользователя (PATCH) — более сильное право
auth.requestMatchers(HttpMethod.PATCH, "/api/admin/users/**")
.hasAuthority("user:manage"); // Это уже "управление", а не "посмотреть"
Здесь есть две важные идеи.
Первая: GET и PATCH — это разные операции, и мы не обязаны «тащить за собой» одно и то же право.
Вторая: мы пишем matcher’ы достаточно узко, чтобы они не захватывали лишнее. Например, PATCH /api/admin/users/** покрывает и roles, и lock, и enable — всё, что меняет пользователя. При этом чтение остаётся отдельно.
Теперь пример на публичной зоне. Мы разрешаем GET на опубликованные статьи всем, а всё остальное под /api/** — минимум аутентификация (это типичный «сначала исключения, потом общая зона»):
import org.springframework.http.HttpMethod;
// внутри authorizeHttpRequests(auth -> auth ...)
// Публичное чтение: разрешаем всем без логина
auth.requestMatchers(HttpMethod.GET, "/api/public/articles", "/api/public/articles/**")
.permitAll();
// Всё остальное API (включая приватные зоны) — только после аутентификации
auth.requestMatchers("/api/**")
.authenticated();
Выглядит почти как человеческий договор: «читать статьи можно всем, а остальное API — только после логина». И тут у нас появляется идеальный повод напомнить главную заповедь security-конфига: порядок правил важен. Вот об этом — следующий раздел.
5. Порядок правил: как “широкий” matcher съедает “узкий”
В конфигурации authorizeHttpRequests правила читаются сверху вниз не только глазами — их также применяет и сама система. Самый частый “невидимый баг” у новичка: он правильно написал два правила, но поставил их в неправильном порядке, и одно из них стало бессмысленным.
Это похоже на ситуацию «я повесил на дверь два замка, но один из них открывается ключом от домофона, поэтому второй уже не важен». Формально замки есть. Практически — ну вы поняли.
Классический пример:
import org.springframework.http.HttpMethod;
// Плохо: сперва широкое правило, потом исключение
// Сначала мы закрыли всё /api/** — ниже "исключение" может уже не сработать так, как ожидается
auth.requestMatchers("/api/**").authenticated();
auth.requestMatchers(HttpMethod.GET, "/api/public/articles/**").permitAll();
В authorizeHttpRequests работает простое правило: применяется первое совпавшее правило сверху вниз. Поэтому matcher "/api/**", поставленный выше, делает нижнее исключение для публичного GET недостижимым. Итог: публичный GET “случайно” стал закрытым.
Правильнее начинать с узких исключений:
import org.springframework.http.HttpMethod;
// Хорошо: сперва исключения, потом общая зона
// Сначала явно фиксируем public-правило, а потом закрываем остаток
auth.requestMatchers(HttpMethod.GET, "/api/public/articles/**").permitAll();
auth.requestMatchers("/api/**").authenticated();
Идея простая: сначала фиксируем специальные случаи (public read, технические исключения, узкие privileged операции), потом накрываем «всё остальное» более широкими правилами.
Почти всегда это можно организовать так, чтобы конфиг читался как карта: public → user zone → privileged zone → закрыть остаток. И вы удивитесь, насколько меньше станет «почему тут 403?!».
6. Примеры для Secure Content Platform API
Сейчас закрепим всё на нашем домене, чтобы это не осталось абстракцией. В Secure Content Platform API такие пары «один префикс — разные методы» встречаются постоянно: админская зона управления пользователями, черновики, модерация.
Начнём с админки, потому что там метод-специфичность почти кричит: чтение не равно изменению.
Вот минимальная “request-level карта” на admin users:
| Endpoint family | Метод | Требование |
|---|---|---|
| /api/admin/users/** | GET | user:read |
| /api/admin/users/** | PATCH | user:manage |
В конфиге это может выглядеть так (кусочек, не весь SecurityFilterChain):
import org.springframework.http.HttpMethod;
// внутри authorizeHttpRequests(auth -> auth ...)
// GET на пользователей — это "посмотреть"
auth.requestMatchers(HttpMethod.GET, "/api/admin/users", "/api/admin/users/**")
.hasAuthority("user:read"); // Минимально достаточное право
// PATCH на пользователей — это "менять"
auth.requestMatchers(HttpMethod.PATCH, "/api/admin/users/**")
.hasAuthority("user:manage"); // Более сильное право для админских действий
Теперь посмотрим на черновики. У нас есть семейство /api/drafts/**. На request-level можно сделать два разумных слоя: во-первых, любые черновики — это не public, значит минимум authenticated(). Во-вторых, некоторые операции можно выразить более узко через authorities, чтобы сам контракт API сразу отражал, что “создавать черновик” — это отдельное право.
С точки зрения курса это выглядит так:
| Endpoint family | Метод | Пример смысла | Возможное требование на request-level |
|---|---|---|---|
| /api/drafts | GET | посмотреть свои черновики | hasAuthority("draft:read:own") или хотя бы |
| /api/drafts | POST | создать черновик | hasAuthority("draft:create") |
| /api/drafts/* | PATCH | изменить черновик | hasAuthority("draft:update:own") |
| /api/drafts/* | DELETE | удалить черновик | hasAuthority("draft:delete:own") |
| /api/drafts/*/submit | POST | отправить на модерацию | hasAuthority("draft:submit:own") |
Ключевой момент: даже если authority называется draft:update:own, сам по себе request-level matcher не доказывает, что черновик действительно “свой”. Он просто говорит: «в принципе у пользователя есть право пытаться обновлять свои черновики». Это нормально для первого слоя. Важно лишь не делать вид, что таким образом мы уже решили всё.
Кодовый фрагмент (опять же, кусочек):
import org.springframework.http.HttpMethod;
// внутри authorizeHttpRequests(auth -> auth ...)
// Создание черновика — отдельная операция с отдельным правом
auth.requestMatchers(HttpMethod.POST, "/api/drafts")
.hasAuthority("draft:create"); // "Создавать" != "читать"
// Обновление конкретного черновика (один сегмент вместо id)
auth.requestMatchers(HttpMethod.PATCH, "/api/drafts/*")
.hasAuthority("draft:update:own"); // Право "в принципе обновлять свои"
// Удаление конкретного черновика
auth.requestMatchers(HttpMethod.DELETE, "/api/drafts/*")
.hasAuthority("draft:delete:own"); // Аналогично: это про возможность, не про доказательство владения
И третий пример — модерация. Тут метод + путь тоже читаются почти как “команда”: publish и reject — это POST-операции над конкретным draft. И идеальный паттерн для пути — как раз *:
import org.springframework.http.HttpMethod;
// внутри authorizeHttpRequests(auth -> auth ...)
// Публикация — отдельная команда, требующая отдельного права
auth.requestMatchers(HttpMethod.POST, "/api/editor/drafts/*/publish")
.hasAuthority("draft:publish"); // Публиковать может не каждый
// Отклонение — другая команда (и часто другое право)
auth.requestMatchers(HttpMethod.POST, "/api/editor/drafts/*/reject")
.hasAuthority("draft:review"); // Например, право ревью/модерации
Заметьте, как приятно читается: один взгляд — и понятно, какие операции вообще существуют в API, и какие права на них нужны. Это один из лучших эффектов хорошо сделанного request-level authorization: он становится документацией, а не «где-то там в голове у тимлида».
7. Границы точности в matcher’ах
Когда впервые чувствуешь, как красиво метод+путь выражают доступ, появляется опасное желание: «а давайте ещё сюда запихнём проверку, что draft именно мой, и что статус DRAFT, и что ещё фаза луны правильная». Это момент, когда нужно остановиться и вспомнить: request-level authorization — это входной контроль, а не полная модель безопасности.
Во-первых, matcher’ы видят запрос, но не видят сущность. Если PATCH /api/drafts/123 пришёл, request-level конфигурация не знает, что такое 123, кому оно принадлежит и в каком оно статусе. Она может только решить: «такой запрос в принципе может войти» или «вообще нет».
Во-вторых, сверхсложные path patterns быстро превращаются в квест. Сегодня вы всё понимаете. Через месяц вы открываете конфиг и спрашиваете себя: «кто и почему написал /api/**/drafts/*/*/publish/** и почему оно работает только по средам?». У security-кода есть неприятная особенность: он может быть “слегка неправильным” и при этом казаться рабочим, пока не случится инцидент.
В-третьих, помните про “читаемость сверху вниз”. Если ради точности вы плодите десятки похожих matcher’ов, вы не усиливаете безопасность — вы усложняете поддержку. Хорошая конфигурация чаще выглядит как несколько крупных блоков (public, authenticated, privileged), а точные method+path правила используются там, где они действительно дают смысл, а не просто шум.
И ещё одна практическая ремарка: если в проекте есть browser-клиент и вы используете CORS, браузер может присылать OPTIONS preflight. Это отдельная тема, но именно method-aware rules иногда становятся причиной «почему в браузере не работает, а в curl работает». Не надо чинить это “магией”; нужно помнить, что метод тоже участвует в матчинге.
8. Типичные ошибки при настройке matcher’ов
Ошибка №1: «все методы одного пути — одно и то же».
Новичок видит /api/admin/users/** и вешает на него одно правило. В результате чтение и изменение получают одинаковый доступ, и authority-модель (user:read vs user:manage) перестаёт иметь смысл. Лечится просто: сначала выделяйте GET отдельно от PATCH/POST/DELETE, и только потом думайте, где можно обобщить.
Ошибка №2: слишком широкий path pattern, который захватывает «лишнее».
Шаблон /api/admin/** иногда кажется удобным, потому что «одна строчка и готово». Но он захватывает всё: и чтение, и управление, и будущие endpoints, о которых вы ещё не подумали. Безопаснее писать узкие matcher’ы на “семейства” (/api/admin/users/**) и только затем накрывать остаток, если это действительно нужно и понятно.
Ошибка №3: неправильный порядок правил.
Даже идеально подобранные matcher’ы превращаются в тыкву, если “общее” правило стоит выше “частного”. Самый частый симптом — публичный endpoint внезапно требует аутентификацию, или наоборот privileged операция внезапно становится доступной “как-то странно”. Воспитывайте привычку: сначала узкие исключения, потом широкие зоны, и всегда в конце — понятное замыкающее правило (обычно denyAll() для остатка).
Ошибка №4: путаница между * и /**.
* — один сегмент, /** — поддерево. Если вы случайно ставите /** там, где нужен один id, вы делаете правило слишком прожорливым: оно начинает совпадать с путями, которые не должны относиться к операции. Если вы ставите * там, где реально есть несколько сегментов, правило “не матчится”, и вы получаете неожиданный 401/403, который выглядит как «Spring Security опять сломался». На самом деле это просто математика шаблонов.
Ошибка №5: попытка решить объектные ограничения только матчерами.
Когда вы начинаете придумывать шаблоны “на все случаи жизни”, это часто означает, что вы пытаетесь выразить бизнес-ограничение в месте, которое видит только URL. Старайтесь держать request-level слой в роли “первого турникета”: он не должен знать всё о домене, он должен защищать границы и зоны.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ