1. Роли как часть устойчивости security-модели
Когда роли живут в коде, например в .roles("ADMIN") у in-memory пользователя, кажется, что всё под контролем: IDE подсказывает, компилятор ругается, да и “ну кто там опечатается”. Но как только роли становятся данными в базе, мы теряем “страховку компилятора” и внезапно оказываемся в мире, где "ADMIN", "admin" и "ROLE_ADMIN" — три разные вселенные, а Spring Security не обязан угадывать, какую вы имели в виду.
В нашем проекте Secure Content Platform API роли — это ключевой coarse-grained слой доступа. У нас есть USER, EDITOR, ADMIN, и эти имена уже “зашиты” в мысленную модель курса, в access matrix и в правила SecurityFilterChain вроде .hasRole("ADMIN"). Поэтому роль — это не просто кусочек текста, который “где-то лежит”. Это часть контракта между тремя местами: базой данных, Java-кодом и Spring Security.
Если роль хранится как произвольная строка, то ошибки будут выглядеть максимально неприятно: приложение запускается, логин проходит, а доступ внезапно не даётся. И это не “красивое исключение на старте”, а тихое 403, которое вы потом будете полдня отлаживать, подозревая всё подряд: фильтры, порядок правил, SecurityContext, заговор Spring Security и ретроградный Меркурий.
Чтобы этого не произошло, мы заранее фиксируем две идеи.
Первая идея в том, что роли — это словарь ограниченного размера, а не поле “введите что угодно”. В нашем курсе это буквально три значения, и это прекрасный повод сделать их жёстко определёнными в коде.
Вторая идея в том, что один аккаунт может иметь не одну роль “по паспорту”, а набор ролей, которые удобно мыслить как “набор флагов доступа”. Это снимает часть боли с наследования привилегий (“админ тоже может всё, что редактор”), не требуя от нас сложных механизмов.
2. Словарь ролей через enum
Когда вы храните роль как строку, вы создаёте себе маленькую текстовую бомбу замедленного действия. Сегодня вы написали "ADMIN", завтра кто-то добавил "Admin", а послезавтра вы ползёте по логам и думаете: “Почему hasRole("ADMIN") не работает? Я же вижу в БД слово Admin… оно почти такое же”. И вот это “почти” — как раз то, что делает системы ломкими.
Самый простой способ перестать жить в мире строковых опечаток — сделать роли enum в Java. Это не усложнение, это возвращение “компиляторной страховки”, которой мы лишились при переходе к БД.
package com.example.securecontent.security.role;
public enum Role {
// Явно фиксируем словарь: ничего «левого» сюда не попадёт без изменения кода
USER,
EDITOR,
ADMIN
}
И на уровне UserAccount мы хотим видеть не Set<String>, а Set<Role>. Тогда любой код, который “работает с ролями”, перестаёт принимать случайные строки. Вместо “а что, если там опечатка?” получаем “такое значение даже не скомпилируется”.
Мини-скелет аккаунта в этом месте становится куда спокойнее для нервной системы:
package com.example.securecontent.security.account;
import com.example.securecontent.security.role.Role;
import java.util.Set;
public class UserAccount {
private String username;
// Роли — как набор значений enum, а не набор произвольных строк
// (геттеры/сеттеры и прочая «обвязка» здесь намеренно опущены)
private Set<Role> roles;
}
Важно заметить один нюанс для новичков: мы фиксируем роли как enum в коде, но в базе они всё равно окажутся значениями в каком-то виде. Обычно это строка "USER"/"ADMIN" или значение в join-таблице. И именно поэтому важно заранее решить, какие именно строки попадут в БД. Мы хотим, чтобы в БД лежало ровно то же имя, что у enum, без “магических префиксов” и без регистрационных сюрпризов.
3. Несколько ролей на аккаунт
На уровне обучения очень хочется сделать так: “у пользователя одна роль, и точка”. Это кажется простым. Но реальность быстро подкидывает странности. Например, мы хотим, чтобы ADMIN мог делать всё, что EDITOR, а EDITOR мог делать всё, что USER. Если хранить ровно одну роль, вам придётся либо внедрять иерархию ролей, либо постоянно вспоминать, что ADMIN — это не “одна роль”, а “роль, в которой спрятаны другие права”.
Есть два простых подхода, и оба могут быть нормальными. Но для учебного проекта особенно удобно мыслить так: аккаунт хранит набор ролей, а не одну. Тогда админ может иметь набор {USER, EDITOR, ADMIN}. Это выглядит чуть более “многословно” в данных, зато вам не нужно объяснять системе, что ADMIN автоматически “включает” остальные возможности.
С точки зрения хранения в БД и работы Java-кода это означает, что roles — это Set<Role>, а не один Role. И это сразу же влияет на выбор схемы хранения: одна колонка role больше не подходит, потому что в одной ячейке вы можете сохранить только одно значение.
Мини-иллюстрация в коде, как мы хотим мыслить ролями:
package com.example.securecontent.security.account;
import com.example.securecontent.security.role.Role;
import java.util.Set;
public class RoleChecks {
public boolean isAdmin(UserAccount account) {
// Проверяем роль «как роль», а не сравниваем строки
return account.getRoles().contains(Role.ADMIN);
}
public boolean isEditor(UserAccount account) {
// Аналогично: читаемо и без риска опечатки
return account.getRoles().contains(Role.EDITOR);
}
}
Здесь нет строк, нет префиксов, нет “а какую именно строку мы договорились использовать”. Всё читается как нормальный Java-код.
4. Варианты хранения ролей в БД
Когда вы впервые переходите к DB-backed users, очень легко перепрыгнуть в крайности. С одной стороны, можно сделать “просто строка в колонке role”, и оно даже будет работать… пока не понадобится второй ролью быть EDITOR. С другой стороны, можно начать строить полноценную модель roles/permissions/policies с таблицами, миграциями и бог знает чем, и внезапно вы уже не изучаете Spring Security, а случайно пишете мини-клон IAM-платформы.
Наша задача — удержать золотую середину: чтобы модель была устойчивой к ошибкам, но при этом оставалась учебной по сложности и объёму.
Ниже — практическое сравнение типичных вариантов хранения ролей. Не как “единственно правильный ответ”, а как инженерная карта местности.
| Вариант хранения | Как выглядит в БД | Что хорошего | Что плохого |
|---|---|---|---|
| Одна роль в одной колонке | user_account.role = 'ADMIN' | Очень просто, легко читать | Нельзя несколько ролей, трудно расширять, быстро упираетесь в ограничения домена |
| Роли в одной строке через запятую | roles = 'USER,EDITOR' | “Быстро и грязно”, можно несколько ролей | Очень хрупко: парсинг, пробелы, регистр, сложно искать и обновлять, сложно ограничивать целостность |
| Роли как JSON-массив | roles = '["USER","EDITOR"]' | Удобно хранить список, иногда удобно читать | БД-специфика, сложнее constraints, усложняется SQL и индексация, не базовый учебный вариант |
| Нормальная join-таблица | user_account_roles(user_id, role) | Нормализовано, легко расширять, проще обеспечить целостность | Нужно чуть больше JPA-аннотаций, появляется отдельная таблица |
Для нашего курса самый понятный и “не игрушечный” вариант — join-таблица. Она не требует делать полноценную сущность RoleEntity, не требует настраивать many-to-many через отдельный класс, но при этом остаётся нормальной, взрослой схемой: у пользователя есть несколько ролей, и они хранятся как отдельные записи.
5. Set<Role> + @ElementCollection + join table
Сейчас будет кусочек JPA, но мы сделаем это максимально “по делу”. Мы не погружаемся в тонкости Hibernate, не строим сложные агрегаты, не обсуждаем fetch planning на уровне Middle. Нам нужно только одно: чтобы UserAccount мог хранить Set<Role>, и чтобы это было стабильно и читаемо.
В JPA для такой задачи есть очень удобный механизм: @ElementCollection. Он подходит, когда у вас есть коллекция простых значений (в нашем случае — enum), и вы хотите хранить её в отдельной таблице, но без выделения отдельной Entity для каждого значения.
package com.example.securecontent.security.account;
import com.example.securecontent.security.role.Role;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import java.util.HashSet;
import java.util.Set;
public class UserAccount {
// В security-контексте роли почти всегда нужны сразу при загрузке пользователя
@ElementCollection(fetch = FetchType.EAGER)
// Join-таблица, где каждая строка — «у такого-то пользователя есть такая-то роль»
@CollectionTable(
name = "user_account_roles",
joinColumns = @JoinColumn(name = "user_account_id")
)
// Храним enum как строку ("ADMIN"), чтобы порядок enum-значений не ломал данные
@Enumerated(EnumType.STRING)
@Column(name = "role")
private Set<Role> roles = new HashSet<>();
}
Здесь важно понимать, что происходит, но без паники.
@ElementCollection говорит: “Это коллекция не-Entity значений, храни её отдельно”. @CollectionTable задаёт таблицу, в которой будет храниться коллекция, и указывает, каким полем она будет ссылаться на UserAccount. @Enumerated(EnumType.STRING) фиксирует, что enum хранится строкой "ADMIN", а не числом 2 (число — плохая идея, потому что поменяете порядок enum-ов, и всё поплывёт).
Отдельно стоит пояснить fetch = FetchType.EAGER. Для обычных коллекций в JPA часто рекомендуют LAZY, но в security-контексте роли почти всегда нужны сразу при загрузке пользователя. Если роли не загрузились вовремя, вы рискуете получить ситуацию “пользователь вроде есть, а ролей нет” или “ролей нет, потому что сессия уже закрыта”. На этом дне мы не уходим в детали ORM, поэтому просто фиксируем практическое правило: роли должны быть доступны тогда, когда Spring Security принимает решения, а значит их нужно загрузить предсказуемо.
Схематично таблицы будут выглядеть так:
erDiagram
%% Основная таблица аккаунтов
user_account {
bigint id
varchar username
varchar email
varchar password_hash
boolean enabled
boolean account_non_locked
}
%% Join-таблица: одна строка = одна роль у конкретного аккаунта
user_account_roles {
bigint user_account_id
varchar role
}
user_account ||--o{ user_account_roles : has
То есть у нас появляется отдельная таблица user_account_roles, где каждая строка — это “у такого-то аккаунта есть такая-то роль”. Это проще и надёжнее, чем хранить роли через запятую в одной строке.
6. Префикс ROLE_ и соглашения
Если вы помните лекции про роли и authorities, у Spring Security есть одна традиция, которая путает новичков сильнее, чем выбор между tabs и spaces. Для роли в Spring Security обычно используется GrantedAuthority вида ROLE_ADMIN. При этом, когда вы пишете .hasRole("ADMIN"), Spring Security сам добавляет префикс ROLE_ под капотом.
Это означает важное соглашение: в вашей доменной модели и базе данных роль лучше хранить как ADMIN, а не как ROLE_ADMIN. Префикс ROLE_ — это больше “язык Spring Security”, чем язык вашего домена. Ваш домен говорит “админ”, а Spring Security говорит “authority ROLE_ADMIN”.
Чтобы не смешивать эти языки, удобно прямо в enum завести метод, который делает “перевод” в формат Spring Security. Сейчас мы ещё не реализуем UserDetailsService и не маппим роли в реальные GrantedAuthority (это уже следующий уровень), но сам метод поможет держать соглашения в одном месте.
package com.example.securecontent.security.role;
public enum Role {
USER,
EDITOR,
ADMIN;
public String asSpringRole() {
// Граница домена и Spring Security:
// в домене — ADMIN, в Spring Security — ROLE_ADMIN
return "ROLE_" + name();
}
}
Этот метод тривиален, но он экономит вам море боли в будущем. Вы перестаёте разбрасывать по коду магические конкатенации "ROLE_" + ... и снижаете шанс случайно получить ROLE_ROLE_ADMIN.
И давайте прямо проговорим типичный “парадокс новичка”: если вы решите хранить в БД роль как "ROLE_ADMIN", а потом где-то в коде по привычке добавите префикс ещё раз, у вас получится "ROLE_ROLE_ADMIN". И ваш .hasRole("ADMIN") никогда не совпадёт с этим значением. Spring Security — не экстрасенс, он не обязан угадывать, что вы имели в виду “ну примерно админ”.
Именно поэтому на уровне хранения мы остаёмся в доменном словаре (ADMIN), а на границе со Spring Security делаем аккуратное преобразование.
7. Роли в данных: join-таблица
Когда вы смотрите на базу глазами новичка, важно, чтобы данные были “самообъясняющимися”. Join-таблица делает это почти идеально: вы можете открыть user_account_roles и буквально глазами увидеть, кто кем является.
Пример того, как это может выглядеть в SQL, упрощённо:
erDiagram
USER_ACCOUNT ||--o{ USER_ACCOUNT_ROLES : has
USER_ACCOUNT {
bigint id PK
varchar username
}
USER_ACCOUNT_ROLES {
bigint user_account_id FK
varchar role
}
Да, у редактора и админа больше строк в таблице, зато всё прозрачно. В такой схеме вам не нужно объяснять базе данных, что ADMIN “включает” USER. Вы просто явно сохраняете набор ролей, и вы можете легко его менять.
Это напрямую поддерживает реальные сценарии проекта. Например, админская операция “сделать пользователя редактором” превращается в понятный апдейт данных: добавить строку EDITOR (или заменить набор ролей, в зависимости от вашей бизнес-логики). И снова: мы не уходим в реализацию админских endpoint-ов сегодня, но модель данных должна не мешать вам их сделать.
Если сравнить это с хранением CSV-строкой, то join-таблица выигрывает ещё и тем, что вы можете поставить ограничения на целостность. Например, сделать role not null, сделать уникальность на пару (user_account_id, role), чтобы одна и та же роль не дублировалась, и так далее. Всё это — вполне “взрослые” практики, но без лишнего усложнения кода.
8. Типичные ошибки при хранении ролей
В этой теме ошибки особенно коварны: приложение часто стартует, логин “как будто работает”, а дальше всё рушится в виде неожиданного 403. Поэтому здесь полезнее не заучивать рецепты, а понимать, где обычно ломается соглашение.
Ошибка №1: хранить роли как произвольные строки и надеяться на дисциплину.
Поначалу кажется, что строка — это просто и удобно. Но как только роли начинают попадать в БД через разные места (сидер, админка, ручные правки, тестовые данные), дисциплина заканчивается, и появляются "admin", "ADMIN " и прочие артефакты человеческой природы. enum Role решает эту проблему тем, что делает словарь ролей “закрытым”.
Ошибка №2: хранить в БД ROLE_ADMIN, а в коде ожидать ADMIN (или наоборот).
Это классический конфликт двух языков: языка домена и языка Spring Security. В домене роль — ADMIN, в Spring Security роль часто выражается authority-строкой ROLE_ADMIN. Если вы смешали их на уровне хранения, вы почти гарантированно однажды получите несовпадение и потратите время на отладку “почему hasRole("ADMIN") не совпадает”. Гораздо спокойнее хранить ADMIN и иметь один метод вроде asSpringRole() на границе.
Ошибка №3: хранить несколько ролей в одной строке через запятую.
Это “быстрый хак”, который потом превращается в бесконечный источник багов: пробелы, регистр, пустые значения, сложность обновления и поиска. Самое неприятное, что такие баги обычно не падают исключением — они просто дают неправильное поведение. Join-таблица убирает весь этот класс проблем и остаётся при этом достаточно простой для курса.
Ошибка №4: пытаться сразу построить сложную permission-модель вместо ролей.
Как только человек узнаёт слово “authority”, появляется желание сделать таблицу permissions, таблицу role_permissions, потом добавить группы, потом сделать UI управления правами… и вот вы уже забыли, что курс вообще-то про fundamentals Spring Security, а не про построение IAM-системы. В нашем проекте на этом этапе роли — главный coarse-grained слой, и этого достаточно, чтобы модель была осмысленной и проверяемой.
Ошибка №5: “одна роль” без явного решения про наследование привилегий.
Если вы храните ровно одну роль, вам надо где-то решить вопрос “админ может как юзер?”. Если вы этого явно не решили, вы получите странный мир, где админ внезапно не может сделать то, что может обычный пользователь, просто потому что правила доступа ожидали USER. Набор ролей (Set<Role>) — простой способ избежать этой ловушки без внедрения дополнительных механизмов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ