JavaRush /Курси /Spring Security /Як зберігати ролі просто й по-дорослому

Як зберігати ролі просто й по-дорослому

Spring Security
Рівень 14 , Лекція 3
Відкрита

1. Ролі як елемент стійкої security-моделі

Коли ролі живуть у коді, наприклад у .roles("ADMIN") у in-memory-користувача, здається, що все під контролем: IDE підказує, компілятор свариться, та й «ну хто там помилиться». Але щойно ролі стають даними в базі, ми втрачаємо «страховку компілятора» і раптово опиняємося у світі, де "ADMIN", "admin" і "ROLE_ADMIN" — це три різні всесвіти, а Spring Security не зобовʼязаний вгадувати, що саме ви мали на увазі.

У нашому проєкті Secure Content Platform API ролі — це ключовий укрупнений рівень доступу. У нас є USER, EDITOR, ADMIN, і ці імена вже закріплені в мисленнєвій моделі курсу, у матриці доступу та в правилах 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 : має

Тобто у нас зʼявляється окрема таблиця 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 : має

  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-системи. У нашому проєкті на цьому етапі ролі — головний укрупнений шар, і цього достатньо, щоб модель була змістовною та перевірюваною.

Помилка № 5: «одна роль» без явного рішення про успадкування привілеїв.
Якщо ви зберігаєте рівно одну роль, вам треба десь вирішити питання «адмін може робити все, що й користувач?». Якщо ви цього явно не вирішили, ви отримаєте дивний світ, де адміністратор раптом не може зробити те, що може звичайний користувач, просто тому, що правила доступу очікували USER. Набір ролей (Set<Role>) — простий спосіб уникнути цієї пастки без упровадження додаткових механізмів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ