1. Зелёный Postman — ещё не гарантия хорошей архитектуры
В разработке очень легко спутать два вида готовности. Первый — функциональная готовность: маршрут существует, код выполняется, данные сохраняются, happy-path тесты проходят. Второй — готовность по доступу: система так же чётко понимает, кто может вызвать действие, в каком состоянии и с какими ограничениями.
Проблема в том, что первый вид готовности отлично виден, а второй долго остаётся невидимым. Если PATCH /api/me/profile возвращает 200, мозг автоматически воспринимает это как завершённость. Но 200 всего лишь означает: «действие технически выполнимо». Он не отвечает на вопрос, законно ли было выполнять его именно этому caller’у.
Именно поэтому backend без access-модели опасен не только из-за дыр, но и из-за ложного чувства качества. Он выглядит собранным, пока вы не начинаете задавать неудобные вопросы: что произойдёт, если запрос придёт без логина? А если его отправит другой пользователь? А если это editor-операция, а caller — обычный USER? И вот тут внезапно выясняется, что зелёный Postman проверял только половину истории.
2. Что CRUD уже доказал, а что — нет
Полезно один раз развести эти две части буквально на бумаге. Тогда становится видно, что уже собрано, а что пока существует только «в голове команды».
| Что уже есть в проекте | Что это не доказывает |
|---|---|
| Есть маршрут и метод контроллера | это не доказывает, что маршрут вызывается только допустимым caller’ом |
| Есть DTO и validation | это не доказывает право на действие |
| Есть сервисная логика и запись в БД | это не доказывает, что система вообще знает, кто делает действие |
| Есть ручная проверка happy path-сценария | это не доказывает, что негативные сценарии доступа закрыты |
Эта таблица кажется почти банальной, пока вы не приложите её к реальному коду. Очень много проектных ошибок рождается именно из-за того, что люди бессознательно читают левую колонку как правую. «Есть DTO» превращается в «значит, всё безопасно». «Есть /api/admin/...» превращается в «значит, туда обычный пользователь не попадёт». «Кнопка скрыта на фронте» превращается в «значит, вызова не будет».
Зрелый backend отличается не тем, что в нём меньше маршрутов, а тем, что он не подменяет одни гарантии другими. Validation остаётся validation. Маршрут остаётся маршрутом. А модель доступа отвечает именно за доступ и не притворяется ничем другим.
3. validation не отвечает на вопрос доступа
Возьмём обычный пример из личной зоны. Он выглядит аккуратно и вполне по-современному.
record UpdateProfileRequest(@NotBlank String displayName) {}
@PatchMapping("/api/me/profile")
String update(@Valid @RequestBody UpdateProfileRequest request) {
return "updated: " + request.displayName();
}
Такой код действительно делает важную работу. Он не пропускает пустое имя, защищает форму входных данных и не даёт приложению обрабатывать откровенно сломанный JSON. Это полезно. Но он не отвечает ни на один из security-вопросов. Кто вызывает метод? Это вообще текущий пользователь? Он подтверждён системе? Имеет ли право изменять именно этот профиль? Ничего из этого валидация не знает.
Отсюда и неприятный, но очень важный вывод: валидный запрос может быть запрещённым 🚫 Более того, в реальном мире именно так чаще всего и бывает. Злоумышленник не обязан присылать кривой JSON. Наоборот, ему обычно выгоднее прислать идеально корректный JSON и воспользоваться тем, что backend проверяет форму, но не проверяет право.
Именно поэтому фраза «у нас же есть validation» никогда не должна звучать как аргумент в разговоре о security. Это другой слой реальности. Validation спрашивает: «правильно ли составлен запрос?» Access-модель спрашивает: «можно ли этому caller’у делать это действие?» И backend обязан отвечать на оба вопроса независимо друг от друга.
4. Самые дорогие ошибки — в операциях изменения состояния 🧨
Когда говорят о security, новички чаще всего думают прежде всего о приватных чтениях: «лишь бы не утекли данные». Это важная тема, но в обычном backend-приложении ещё болезненнее оказываются операции изменения состояния. Если система позволяет не тому человеку менять роль, удалять черновик, блокировать пользователя или публиковать материал, проблема уже не в том, что кто-то «увидел лишнее». Проблема в том, что система изменилась неправильно.
class AdminService {
void changeUserRole(long userId, String newRole) {
// Опасная операция: меняет модель доступа другого пользователя
}
}
Сам факт существования такого метода уже означает, что вокруг него должна быть явная модель допуска. И это касается не только админских действий. DELETE /api/drafts/{id}, POST /api/editor/drafts/{id}/publish, PATCH /api/me/profile — всё это операции изменения состояния. Они меняют данные, статус, видимость контента, а иногда и саму политику доступа внутри системы.
Если в этот момент у backend нет ответа на вопрос «кто делает запрос и почему ему это можно», то приложение не просто незавершённо. Оно умеет ошибаться по-крупному 💥
5. URL и скрытые кнопки не защищают систему
Одна из самых живучих иллюзий — вера в то, что путь уже выражает доступ. Маршрут называется /api/admin/users/{id}/roles, значит, видимо, только админ его и вызовет. На уровне надежд это звучит приятно. На уровне HTTP это просто строка.
Точно так же работает и иллюзия «но на фронте же нет такой кнопки». UI действительно может скрыть действие от обычного пользователя. Но UI не контролирует curl, Postman, самописный клиент, тестовый скрипт и вообще любой прямой HTTP-запрос. Backend не получает никаких дополнительных очков за то, что опасная кнопка спрятана в интерфейсе 😅
Иногда из этого рождаются совсем хрупкие обходные пути:
@GetMapping("/api/me")
ProfileDto me(@RequestParam long userId) {
return profileService.findById(userId);
}
Такой код особенно полезно увидеть глазами security. Кажется, будто мы «быстро решили» вопрос текущего пользователя. На самом деле мы попросили клиента самому сообщить, кто он такой. Это не аутентификация, а просто доверие к параметру запроса. И если caller может сам назвать userId, он почти наверняка однажды назовёт не свой.
6. У endpoint’а всегда два контракта: функциональный и access-контракт
Вот главная мысль дня: у каждого endpoint’а есть два разных контракта
Первый контракт — функциональный. Он отвечает на вопрос: что делает система? Возьмём POST /api/drafts/{id}/submit. Функционально это означает: отправить черновик на модерацию. Это бизнес-смысл действия.
Второй контракт — access-контракт. Он отвечает на другой вопрос: кто может отправить черновик на модерацию? Любой анонимный клиент? Любой аутентифицированный пользователь? Только владелец этого черновика? Может ли editor отправлять чужие черновики? Вот здесь и начинается настоящий дизайн доступа.
Пока спроектирован только первый контракт, backend остаётся половинчатым. Да, он умеет выполнять действие. Но он не умеет описывать границу системы. Именно поэтому security в нормальном курсе не появляется в формате «вот ещё одна настройка поверх готового CRUD». Она появляется как продолжение архитектуры.
Предыдущие курсы учили вас проектировать функцию. Этот курс добавляет вторую половину — проектирование допуска
7. Типичные ошибки 🚧
Ошибка №1: считать validation заменой правила доступа.
Аккуратный DTO и строгое @Valid создают иллюзию контроля, но отвечают совсем на другой вопрос. Даже идеальный по форме запрос может быть абсолютно незаконным по смыслу. Если держать это в голове с самого начала, половина типичных security-мифов просто не успевает поселиться.
Ошибка №2: замечать только утечки чтения и забывать про операции изменения состояния.
Очень часто команда думает о «закрытых данных», но недооценивает удаление, публикацию, смену ролей, блокировки и другие действия, меняющие состояние. А именно там backend обычно получает самые дорогие ошибки, потому что система не только раскрывает лишнее, но и меняет себя неправильно.
Ошибка №3: доверять клиенту в вопросе identity.
Параметры вроде userId, самодельные заголовки вида X-User-Id и похожие обходные пути всегда выглядят как ускорение разработки. На деле это способ попросить клиента самому рассказать, кем он хочет считаться. Такое ускорение потом почти всегда превращается в долг.
Ошибка №4: описывать endpoint только через данные, а не через caller’а.
Если при проектировании маршрута вы формулируете только DTO, статус ответа и бизнес-эффект, но не проговариваете, кто может его вызвать, значит половина дизайна остаётся неявной. А неявные правила доступа почти всегда становятся противоречивыми.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ