1. authenticated() — вход, не доступ
Если вы уже привыкли к URL-правилам вида «/api/drafts/** доступно всем аутентифицированным», то легко попасть в ловушку: кажется, что раз пользователь вошёл в систему и имеет роль USER, значит он «в private-зоне» и может работать с черновиками. Но private-зона — это не «общая кухня», а набор личных комнат. И черновик — чаще всего личный.
Представим типичный endpoint:
GET /api/drafts/42 — получить черновик по id.
На уровне request rules мы обычно ставим что-то вроде: «если ты аутентифицирован — можешь заходить». И это правильно: anonymous тут делать нечего. Но дальше появляется главный вопрос: какому аутентифицированному пользователю можно видеть этот конкретный черновик?
Проблема в том, что роль USER отвечает на вопрос «какой у тебя общий уровень доступа» (coarse-grained). Ownership отвечает на вопрос «этот конкретный объект принадлежит тебе?» (object-level). Эти вопросы не конкурируют, они просто лежат на разных уровнях.
Чтобы это почувствовать, удобно держать в голове простую таблицу:
| Механизм | Отвечает на вопрос | Пример | Что НЕ решает |
|---|---|---|---|
| hasRole("USER") | «Ты вообще пользователь?» | вход в private-зону | чей именно черновик |
| hasAuthority("draft:read:own") | «Тебе можно читать свои черновики?» | право на тип действия | является ли данный draftId «своим» |
| Owner-check | «Этот черновик твой?» | сравнение authorId и currentUserId | не заменяет роли/authority |
И вот здесь важный, почти философский тезис: роль — это про «кто ты в системе», ownership — про «какие объекты в системе твои». Это разные координаты.
2. Модель ownership: actor, resource, action
Когда мы говорим «свой / чужой», полезно перестать думать категориями «endpoint открыт/закрыт» и начать думать как security-движок (или как охранник в бизнес-центре, которому дали инструкции и кофе). Любое решение доступа — это проверка связки: кто делает → что делает → с чем делает. И ownership почти всегда живёт в третьей части — «с чем».
В терминологии, удобной для backend-разработчика, у нас есть три сущности:
Actor (субъект) — текущий пользователь, которого мы получили из SecurityContext (например, principal.getUserId()).
Resource (объект) — доменная сущность: ContentItem, UserProfile, StoredFile.
Action (действие) — операция: read/update/delete/submit/publish.
Owner-only правило формулируется очень буднично: «действие разрешено, если actor является owner ресурса». Технически это означает: «в ресурсе есть поле owner (authorId, userId, ownerUserId), и мы сравниваем его с trusted identifier текущего пользователя».
Вот схема, которая помогает не запутаться, где именно проверка должна жить:
flowchart TD
A[HTTP request] --> B[SecurityFilterChain]
B --> C[Controller]
C --> D[Service: business action]
D --> E[Load resource from DB]
E --> F{"ownerId == currentUserId?"}
F -->|yes| G[Perform action]
F -->|no| H["AccessDeniedException -> 403"]
Обратите внимание на важный момент: ownership не проверяется «в воздухе». Её нельзя честно проверить, не имея хотя бы двух вещей: trusted идентификатора текущего пользователя и поля владельца у ресурса. Поэтому ownership — это почти всегда сервисный уровень (или слой рядом с бизнес-действием), а не просто красивый matcher в SecurityFilterChain.
3. Owner-check в service layer
Иногда кажется, что если мы уже включили @PreAuthorize, то можно «написать одну аннотацию — и безопасность готова». К сожалению (или к счастью для вашей будущей зарплаты), реальный мир так не работает. Ownership-проверка почти всегда превращается в очень простой код: загрузили объект, сравнили ownerId, если не совпало — запретили.
Начнём с маленького фрагмента модели. У нашего черновика есть автор:
package com.example.securecontent.content;
public class ContentItem {
private Long id;
private Long authorId; // id владельца (автора) — именно его сравниваем с currentUserId
public Long getId() { return id; }
public Long getAuthorId() { return authorId; }
}
Да, это не весь entity-класс, но нам сейчас важна именно идея: у ресурса есть владелец.
Теперь — контроллер. Мы не доверяем клиенту authorId и не просим его передать «я точно пользователь 123, честно-честно». Мы берём current user из security-контекста и передаём в сервис trusted identifier:
package com.example.securecontent.content;
import com.example.securecontent.security.AppUserPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DraftController {
private final DraftService draftService;
DraftController(DraftService draftService) {
this.draftService = draftService;
}
@GetMapping("/api/drafts/{id}")
ContentItem getDraft(@PathVariable Long id,
@org.springframework.security.core.annotation.AuthenticationPrincipal
AppUserPrincipal principal) {
// principal — trusted источник userId: он пришёл из SecurityContext, а не от клиента
return draftService.getOwnDraft(id, principal.getUserId()); // trusted userId
}
}
Сервис — место, где мы принимаем решение «свой/чужой», потому что именно тут находится бизнес-действие. Сначала делаем наивно-прозрачный вариант (две строчки, которые делают безопасность реальной):
package com.example.securecontent.content;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
@Service
class DraftService {
private final ContentRepository contentRepository;
DraftService(ContentRepository contentRepository) {
this.contentRepository = contentRepository;
}
ContentItem getOwnDraft(Long draftId, Long currentUserId) {
// Сначала грузим ресурс, потому что ownership без ownerId ресурса не проверяется
ContentItem draft = contentRepository.findById(draftId).orElseThrow();
// Важно: сравниваем именно идентификаторы, а не "роль пользователя"
if (!draft.getAuthorId().equals(currentUserId)) {
// Это сознательно 403 (forbidden) для аутентифицированного пользователя
throw new AccessDeniedException("Чужой черновик");
}
return draft;
}
}
Если draftId не существует, дальше должен сработать обычный not-found path проекта. AccessDeniedException здесь нужна именно для случая, когда объект найден, но он чужой.
Здесь два суперважных практических нюанса, которые часто ломают новичков.
Во-первых, сравнивать Long нужно через equals(), а не через ==. == сравнивает ссылки, и баг будет «то работает, то нет». Такой баг — идеальный кандидат на ночной дежурный кошмар.
Во-вторых, AccessDeniedException — это не просто исключение «для красоты». Раз у нас уже есть AccessDeniedHandler для JSON API, это исключение превращается в корректный 403 с нашим JSON error contract, а не в «ой, опять HTML-страница».
Если хочется дополнительно сузить выборку на уровне запроса к данным, можно иметь репозиторный метод, который сразу умеет искать объект в owner-bound контексте. Это полезно как вспомогательный паттерн, но не подменяет нашу политику ответов:
package com.example.securecontent.content;
import java.util.Optional;
interface ContentRepository {
Optional<ContentItem> findById(Long id);
// Вариант "уже с owner-bound фильтрацией": вернёт результат только если совпал владелец
Optional<ContentItem> findByIdAndAuthorId(Long id, Long authorId);
}
Но здесь важно не потерять каноническую политику ответов. В baseline этого проекта мы не смешиваем not found и foreign: если объекта нет — это 404, если объект существует, но чужой — это 403. Поэтому основным для owner-check остаётся явный вариант выше: загрузили объект, сравнили owner, при несовпадении бросили AccessDeniedException. А findByIdAndAuthorId(...) удобно держать как дополнительный защитный паттерн, а не как замену этой политике.
4. API для self: зона /api/me/**
Когда вы впервые проектируете API, рука часто тянется к «классическому REST»: /api/users/{id}/profile, /api/users/{id}/drafts. И это нормально, это привычно. Но для self-сценариев (операции «от моего имени») такой дизайн создаёт лишнюю поверхность атаки: клиент начинает присылать вам id, и вам приходится каждый раз доказывать, что этот id действительно его.
В учебном проекте мы специально держим отдельную зону /api/me/**, чтобы self-сценарии были «натуральными»: сервер и так знает, кто пришёл. И тогда ownership проверяется не как «совпадает ли path userId с current userId», а как «мы вообще не принимаем userId снаружи».
Сравните два подхода. Сомнительный для self-операций вариант (не обязательно «плохой всегда», но более рискованный):
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
class ProfileController {
@PatchMapping("/api/users/{userId}/profile")
void updateProfile(@PathVariable Long userId,
UpdateProfileRequest request) {
// Антипаттерн для self: client-controlled userId
// Если забыть проверить userId == currentUserId,
// вы “подарите” редактирование чужого профиля.
}
}
Self-friendly вариант, который гораздо проще правильно защитить:
package com.example.securecontent.profile;
import com.example.securecontent.security.AppUserPrincipal;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeProfileController {
private final ProfileService profileService;
MeProfileController(ProfileService profileService) {
this.profileService = profileService;
}
@PatchMapping("/api/me/profile")
void updateMyProfile(@AuthenticationPrincipal AppUserPrincipal principal,
@RequestBody UpdateProfileRequest request) {
// В этом API клиент НЕ может передать чужой userId: он всегда берётся из SecurityContext
profileService.updateOwnProfile(principal.getUserId(), request); // owner by design
}
}
Здесь ownership встроен в саму форму API: у клиента просто нет места, куда он может «подсунуть чужой id». Это не отменяет проверки доступа (endpoint всё равно должен быть authenticated()), но сильно снижает шанс логической уязвимости «я случайно поверил клиенту».
5. Ownership + method security без простынь
После Дня 19 у нас появился мощный инструмент — method security. И тут возникает соблазн: «а давайте ownership выразим целиком в @PreAuthorize». Иногда так действительно делают, но начинающим важно запомнить более приземлённую, устойчивую мысль: method security и owner-check — это слои, которые удобно сочетать, а не пытаться запихнуть в одну строку SpEL любой ценой.
Типичный здравый baseline выглядит так: аннотация защищает метод по роли/authority (то есть по общему праву на действие), а внутри метода остаётся маленькая проверка владельца конкретного объекта. Тогда код читается людьми, а не только компилятором.
Пример на чтение черновика:
package com.example.securecontent.content;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class DraftService {
private final ContentRepository repo;
DraftService(ContentRepository repo) {
this.repo = repo;
}
@PreAuthorize("hasAuthority('draft:read:own')") // право на тип действия (читать свои черновики)
ContentItem getOwnDraft(Long draftId, Long currentUserId) {
// Owner-check нельзя сделать, пока не загрузили ресурс (нужен authorId)
ContentItem draft = repo.findById(draftId).orElseThrow();
if (!draft.getAuthorId().equals(currentUserId)) {
// authority != ownership: право есть, но объект может быть чужим
throw new AccessDeniedException("Чужой черновик");
}
return draft;
}
}
На человеческом языке это читается так: «вообще читать свои черновики можно только тем, у кого есть authority draft:read:own, и даже если она есть — конкретный черновик надо проверить по автору».
Если вы хотите сделать метод ещё более «говорящим», можно вынести проверку ownership в отдельный маленький компонент. Это полезно, когда проверка повторяется для update/delete/submit, а вы не хотите копировать if (!equals) в пять методов. Мы не строим ACL и policy engine, мы просто делаем маленький helper.
package com.example.securecontent.content;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Component;
@Component
class DraftOwnership {
void assertOwner(ContentItem draft, Long currentUserId) {
// Единый стандарт проверки: одно место — меньше шанс сделать "похожую, но другую" проверку
if (!draft.getAuthorId().equals(currentUserId)) {
throw new AccessDeniedException("Чужой черновик");
}
}
}
И в сервисе получается аккуратнее:
package com.example.securecontent.content;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class DraftService {
private final ContentRepository repo;
private final DraftOwnership ownership;
DraftService(ContentRepository repo, DraftOwnership ownership) {
this.repo = repo;
this.ownership = ownership;
}
@PreAuthorize("hasAuthority('draft:update:own')") // право на обновление своих черновиков
void updateOwnDraft(Long draftId, Long currentUserId, UpdateDraftRequest req) {
ContentItem draft = repo.findById(draftId).orElseThrow();
ownership.assertOwner(draft, currentUserId); // owner-check — часть бизнес-действия
// draft.update(req) — условно, бизнес-логика
}
}
Заметьте важную «методическую честность»: authority называется draft:update:own, но она не делает черновик «own» автоматически. Она говорит лишь: «пользователь имеет право обновлять свои черновики». А вот «свои или не свои» выясняется через ownership-check.
6. Editor/Admin и ownership: разные ветки
На этом месте часто происходит путаница уровня «все проверки в один котёл»: разработчик пытается в одном методе учесть и владельца, и редактора, и админа, и «на всякий случай ещё и котика». В итоге получается условие на три экрана, и никто не уверен, что оно правильное. В реальном проекте это прямой путь к случайно открытым данным.
Держим модель курса: owner-only — отдельный тип доступа, а editor/admin — это привилегированные ветки. Черновик может быть «не мой», но редактор может иметь право его читать в очереди модерации, а админ — читать для расследований. Это не делает черновик «моим», это делает действие «разрешённым по привилегии».
Практически это выражается так: в owner-методах мы проверяем owner, а для привилегированных операций делаем отдельные методы и отдельные права. Например, чтение черновика владельцем:
@PreAuthorize("hasAuthority('draft:read:own')")
ContentItem getOwnDraft(Long draftId, Long currentUserId) { ... }
И чтение черновика для модерации (другой use case, другое право, другой endpoint):
package com.example.securecontent.content;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class EditorDraftService {
private final ContentRepository repo;
EditorDraftService(ContentRepository repo) {
this.repo = repo;
}
@PreAuthorize("hasAuthority('draft:review')") // привилегия модерации, не "ownership"
ContentItem getDraftForReview(Long draftId) {
return repo.findById(draftId).orElseThrow(); // owner-check не нужен: это не owner-метод
}
}
Психологически важно принять: если вы добавили editor/admin bypass внутрь owner-метода, вы уже смешали два мира. Иногда это допустимо в маленьком проекте, но как учебный baseline лучше держать ветки отдельно, потому что так легче читать и тестировать.
Ownership и коды 401/403
Когда вы внедряете правило «свой / чужой», вопросы про 401 и 403 начинают возникать постоянно. И это хорошо: значит, у вас в голове появляется правильная связь между механикой и контрактом API. Логика здесь простая, но её полезно проговорить вслух, чтобы не путать.
Если запрос делает anonymous пользователь (не аутентифицирован), но пытается попасть в private-зону, то проблема в отсутствии аутентификации. Это 401. Ваш AuthenticationEntryPoint (который мы настраивали для JSON API) отдаст правильный ответ.
Если запрос делает аутентифицированный пользователь (он «кто-то»), но этот «кто-то» пытается сделать действие над чужим объектом, то проблема в отсутствии права доступа к ресурсу. Это 403. Самый естественный способ «сигнализировать» об этом внутри сервиса — AccessDeniedException.
И это ещё одно преимущество размещения owner-check в service layer: именно там вы понимаете контекст, и именно там вы можете решить, что это forbidden, а не «просто не нашли». На уровне контроллера без знания бизнес-смыслов часто начинаются кривые решения вроде «вернём 404, чтобы никто не догадался». Это отдельная стратегия (иногда допустимая), но в рамках курса мы держим поведение простым и наблюдаемым: чужое — это 403.
7. Типичные ошибки при owner-only доступе
Когда люди впервые внедряют ownership, они обычно наступают не на «сложные криптографические грабли», а на самые бытовые — и именно поэтому они такие опасные: мозг говорит «да тут же всё очевидно», а через месяц вы находите уязвимость уровня «пользователь меняет чужой профиль». Ниже — набор ошибок, которые стоит научиться узнавать по запаху.
Ошибка №1: брать userId для self-операций из запроса (path/query/body).
Это классика жанра: endpoint принимает userId, а вы «честно» обновляете профиль по этому id. Так система начинает верить словам клиента о том, кто он такой. В self-сценариях trusted идентификатор должен приходить из SecurityContext, а не из JSON.
Ошибка №2: ограничиться authenticated() и считать, что ownership «как-нибудь само».
authenticated() действительно закрывает endpoint от anonymous, но никак не отличает «моё» от «чужого». Поэтому endpoint /api/drafts/{id}, открытый для всех аутентифицированных без дополнительной проверки, почти гарантированно превратится в «читать любой черновик по id».
Ошибка №3: путать право типа draft:read:own с фактом владения конкретным черновиком.
Authority — это строка, которая говорит «пользователю разрешён тип действия», но она не знает, чей конкретно объект сейчас запрошен. Если вы не сравнили authorId с currentUserId, то вы не сделали ownership-check, даже если назвали authority очень убедительно.
Ошибка №4: сделать owner-check только в контроллере, а сервис оставить «чистым».
Пока у вас один контроллер и один endpoint, это «кажется» работает. Но как только появляется второй вход в сервис (другой endpoint, batch операция, интеграция), сервис становится дырой: он выполняет бизнес-действие без проверки, потому что вы вынесли security в HTTP-слой. В результате один новый контроллер может случайно открыть доступ к чужим объектам.
Ошибка №5: сравнивать Long через == и получать «призрачные» баги доступа.
Это особенно мерзкая ошибка, потому что она не всегда проявляется на локальной машине. Сегодня == сработал, завтра — нет, а послезавтра ваш тимлид начал подозревать, что у вас «иногда неправильный пользователь». Для object-id всегда используйте equals().
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ