JavaRush /Курси /Spring Security /Ролі USER /

Ролі USER / EDITOR / ADMIN

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

1. Фіксуємо ролі без бюрократії

Якщо ви колись бачили проєкт, де доступ обмежують фразою «ну це ж очевидно, що сюди не можна», то ви бачили проєкт, який одного дня доведеться лагодити вночі під звуки тихого плачу. Ролі потрібні не для краси й не для галочки, а щоб перетворити безпеку на домовленість, записану в коді. І чим раніше ми це зробимо, тим менше буде сюрпризів.

До цього моменту ми вже використовували правила на кшталт .hasRole("ADMIN"), але вони були трохи «у вакуумі»: не було чіткого узгодження, які ролі взагалі існують, як вони називаються і що означають у домені проєкту. У результаті конфігурація легко перетворюється на набір рядків, які схожі на заклинання: працюють, але страшно чіпати.

Фіксація рольової моделі на цьому етапі особливо важлива з методичної причини: ми ще не додали БД, form login, сесії й усе інше, що додасть ще більше рухомих частин. Зараз ідеальний момент зробити одну просту річ: домовитися про три ролі і тримати їх стабільними в усьому курсі. Це дозволить нам далі розвивати проєкт без відчуття, що безпеку щодня вигадують заново.

Роль як бейджик зони доступу

У слові role легко почути «роль у театрі»: один грає героя, інший — лиходія. Але в Spring Security роль — це набагато приземленіша річ: це ярлик, який каже системі, до якої зони доступу належить користувач. Роль відповідає на запитання «в якій кімнаті цій людині можна перебувати», а не «які саме кнопки їй можна натискати». Це рівень із грубою деталізацією.

Поки ми на базовому етапі, нам важливо зробити рольову модель короткою і читабельною. Для нашого проєкту роль — це спосіб не розписувати для кожного URL окремі міні-дозволи. Ми говоримо: «це користувач», «це редактор», «це адміністратор». І вже поверх цього далі можна буде уточнювати правила точковими правами (authorities), але сьогодні ми фокусуємося саме на ролі як на «перепустці в зону».

Ще один важливий момент: роль — це не «тип облікового запису в базі», не «клас користувача» і не enum у домені, який усе вирішує. У Spring Security роль — це частина авторизації (прав доступу), а не аутентифікації (підтвердження особи). Тобто спочатку ми дізнаємося, хто ви, а потім дивимося, які у вас ролі.

2. Рольова модель проєкту: USER, EDITOR, ADMIN

Щоб ролі не були абстракцією, привʼяжемо їх до нашої предметної області. У цьому проєкті ми будуємо платформу контенту: є публічні статті, особиста зона користувача, редакторська модерація та адміністрування користувачів. На цьому домені три ролі справді природні й не виглядають штучно. Важливо, що вони короткі, стабільні і однаково читабельні у коді та в розмові.

Давайте зафіксуємо зміст ролей так, щоб потім не додумувати його «за натхненням»:

Роль Що означає в проєкті Приклад зони (шлях)
USER звичайний автентифікований користувач, у якого є особиста зона /api/me/**, /api/drafts/**
EDITOR користувач, який може модерувати та публікувати контент /api/editor/**
ADMIN користувач, який керує користувачами та їхніми станами /api/admin/**

Зверніть увагу: публічні кінцеві точки (/api/public/**) ролі не потребують узагалі — це окрема зона, яка живе за правилом permitAll(). Тобто рольова модель починається не з «публічного» (це не роль), а з першого захищеного рівняUSER.

Якщо хочеться побачити це як карту, можна уявити дуже просту схему зон:

flowchart TD
    A[анонімний користувач] -->|GET /api/public/**| P[публічна зона]
    U[роль USER] -->|/api/me/**, /api/drafts/**| M[особиста зона]
    E[роль EDITOR] -->|/api/editor/**| R["зона рецензування/модерації"]
    AD[роль ADMIN] -->|/api/admin/**| Z[адмінська зона]

Насправді редактор і адмін зазвичай теж «люди», у яких є профіль і особиста зона. Тому ми майже завжди хочемо, щоб EDITOR і ADMIN могли проходити перевірку особистої зони. Але — і це ключовий момент лекції — це не відбувається автоматично.

3. ADMIN не зобовʼязаний бути USER

Коли починаєте працювати з ролями, дуже хочеться подумки побудувати піраміду: USER < EDITOR < ADMIN, отже ADMIN може все, що може USER. Логіка людська, але Spring Security не зобов’язаний вгадувати ваші наміри. Він чесно порівнює умови доступу з тим, що є у користувача. Якщо правило написано як .hasRole("USER"), то перевіряється роль USER. І все. Без телепатії.

У цьому навіть є плюс: система стає передбачуваною. Мінус — новачок часто ловить «чому адмін не може зайти в /api/me?», а потім починає виправляти це найнебезпечнішим способом: розширювати правила доступу «про всяк випадок», відкриваючи зайве.

Давайте прямо зафіксуємо реальний сценарій. Уявімо, що ми написали правило:

// Доступ до особистої зони: потрібна роль USER
.requestMatchers("/api/me/**").hasRole("USER")

А потім створили адмінського користувача ось так:

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

// Створюємо користувача, який має лише роль ADMIN (без USER)
UserDetails root = User.builder()
    .username("root")
    .password("{bcrypt}...")
    .roles("ADMIN")
    .build();

Такий користувач не пройде перевірку hasRole("USER"), тому що в нього немає ролі USER. І це не «особливість навчального прикладу», а базова інженерна реальність: Spring Security не вводить ієрархію ролей автоматично.

Так, у Spring Security існує механізм RoleHierarchy, який дозволяє описати «ADMIN > EDITOR > USER». Але на етапі базового вивчення це часто робить гірше, тому що приховує важливу думку: доступ має бути явним. Зараз ми хочемо не «полагодити незручність», а навчитися бачити, які правила справді перевіряються.

Тому наш поточний принцип простий: якщо обліковий запис має проходити кілька перевірок за ролями, ми явно призначаємо йому кілька ролей.

4. Ролі для in-memory користувачів

Оскільки користувачі у нас зараз живуть у памʼяті, ми можемо зробити дуже зручну для навчання річ: створити кілька сценарних облікових записів, кожен із яких існує «з причини». Один потрібен, щоб перевіряти особисту зону, другий — редакторську, третій — адмінську. Якщо замість цього зробити одного «суперкористувача», перевірка матриці доступу перетворюється на ворожіння: «а це справді працює, чи я просто завжди заходжу під root?».

Давайте заведемо трьох користувачів:

  • alice — звичайний користувач із роллю USER;
  • eva — редактор із ролями USER та EDITOR;
  • root — адміністратор із ролями USER, EDITOR, ADMIN (так, це сильний адмін, але для навчання зручно).

Поки що дивімося на це саме як на рольову модель. Формат зберігання конкретних authority-рядків тут не головне питання; зараз важливо зафіксувати зміст ролей і прибрати ілюзію, що ієрархія зʼявиться сама.

Спочатку покажу ідею на рівні окремих користувачів — невеликими фрагментами, щоб не перевантажувати погляд:

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;

UserDetails alice(PasswordEncoder encoder) {
    return User.builder()
        .username("alice")
        // Пароль завжди кодуємо через PasswordEncoder, а не зберігаємо як plain text
        .password(encoder.encode("alice-pass"))
        // roles(...) приймає "людські" назви ролей без префікса ROLE_
        .roles("USER")
        .build();
}

Тут важливі дві речі. По-перше, пароль ми кодуємо через PasswordEncoder, який уже налаштований у застосунку. По-друге, роль задаємо без префікса ROLE_ — просто "USER". У roles(...) ми пишемо людські назви ролей, а сам префікс зʼявиться вже тоді, коли Spring Security перетворить роль на реальний authority-рядок.

Тепер редактор:

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;

UserDetails eva(PasswordEncoder encoder) {
    return User.builder()
        .username("eva")
        .password(encoder.encode("eva-pass"))
        // Явно видаємо обидві ролі: EDITOR не «включає» USER автоматично
        .roles("USER", "EDITOR")
        .build();
}

Зверніть увагу: ми не сподіваємося, що EDITOR «включає» USER. Ми говоримо про це явно.

І адмін:

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;

UserDetails root(PasswordEncoder encoder) {
    return User.builder()
        .username("root")
        .password(encoder.encode("root-pass"))
        // Для навчального проєкту зручно, що root може проходити перевірки всіх зон
        .roles("USER", "EDITOR", "ADMIN")
        .build();
}

Знову те саме правило: якщо ми хочемо, щоб адмін міг ходити в особисту зону, ми явно видаємо йому роль USER. Якщо хочемо, щоб він міг ходити в редакторську зону (а в навчальному проєкті це зручно для перевірки), даємо EDITOR.

Тепер зберемо користувачів у InMemoryUserDetailsManager. Я покажу варіант максимально близький до реального коду конфігурації, але без зайвого шуму:

import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
    // InMemoryUserDetailsManager зберігає користувачів у памʼяті застосунку (без БД)
    return new InMemoryUserDetailsManager(
        // USER: доступ лише до особистої зони
        User.withUsername("alice").password(encoder.encode("alice-pass")).roles("USER").build(),
        // USER + EDITOR: доступ до особистої та редакторської зон
        User.withUsername("eva").password(encoder.encode("eva-pass")).roles("USER", "EDITOR").build(),
        // USER + EDITOR + ADMIN: доступ до всіх зон
        User.withUsername("root").password(encoder.encode("root-pass")).roles("USER", "EDITOR", "ADMIN").build()
    );
}

Так, тут рядки щільні, але зате видно головну ідею: кожен обліковий запис явно отримує набір ролей, і цей набір збігається з тим, які зони ми хочемо йому відкрити. У навчальному проєкті це майже ідеальна повторюваність: кожен запуск застосунку дає однакових користувачів і однакову поведінку.

Тут ми фіксуємо саме зміст ролей. Якщо потім подивитися на ті самі облікові записи вже як на точні authority-рядки, логіка не зміниться: alice залишається звичайним користувачем, eva — редактором, root — адміністратором. Так карта зон перестає плавати, навіть коли запис прав стає більш явним.

5. Ролі та зони в SecurityFilterChain

Тепер ми зʼєднуємо ролі з тим, для чого вони взагалі існують, — з правилами доступу до URL-адрес. І тут важливий принцип: SecurityFilterChain має читатися як «карта місцевості». Коли ви відкриваєте файл безпеки через місяць, ви повинні бачити, де public, де user, де editor, де admin. Якщо замість цього там набір випадкових matcher-ів, то це вже не security-конфігурація, а археологічна пам’ятка епохи «скопіював із туторіалу».

У нашому проєкті рольова модель ідеально лягає на зони доступу:

  • /api/public/** — доступний усім;
  • /api/me/** і наші особисті речі — доступні USER;
  • /api/editor/** — доступні EDITOR;
  • /api/admin/** — доступні ADMIN;
  • усе інше — закрите (denyAll()), щоб випадково не відкрити щось «забуте».

Приклад конфігурації (коротко, але вже змістовно):

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http.authorizeHttpRequests(auth -> auth
            // Публічна зона: доступ без аутентифікації
            .requestMatchers("/api/public/**").permitAll()
            // Особиста зона: потрібна роль USER
            .requestMatchers("/api/me/**").hasRole("USER")
            // Редакторська зона: потрібна роль EDITOR
            .requestMatchers("/api/editor/**").hasRole("EDITOR")
            // Адмінська зона: потрібна роль ADMIN
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            // Усе інше закриваємо, щоб випадково не залишити дірку
            .anyRequest().denyAll()
    ).build();
}

Зараз тут головне — не «ідеальне фінальне налаштування на всі випадки життя», а читабельна базова карта. Ми тримаємо конфігурацію короткою. Ми не розписуємо кожний endpoint окремо — це швидко перетворить конфіг на простирадло. Ми використовуємо ролі за призначенням: як доступ до зони.

Якщо загальний matcher стоїть вище за окремі зони, він перехоплює запит раніше, ніж ви дійдете до потрібного правила. Тому тут працює та сама логіка: більш конкретні правила — вище, загальний варіант denyAll() — в кінці. І саме в такому вигляді рольова модель починає «відчуватися» не як теорія, а як працююча система.

6. Константи для ролей замість рядків

Поки ми пишемо ролі рядками "USER", "EDITOR", "ADMIN", усе здається простим. Але в рядків є неприємна особливість: друкарська помилка компілюється чудово. Написали "EDTIOR" — і привіт, пів години дебагу, чому у вас «нібито є роль», але доступ не працює. Тому хороший фундаментальний навик — централізувати імена ролей.

У навчальному проєкті це можна зробити дуже просто: завести клас із константами. Не тому, що «так треба за стандартом», а тому, що ви хочете, щоб IDE допомагала вам автодоповненням, а компілятор хоча б опосередковано ловив помилки: ви не зможете послатися на неіснуючу константу.

Наприклад:

package com.example.securecontent.security;

// Єдиний словник ролей для всього проєкту (щоб не ловити помилки в рядках)
public final class AppRoles {
    public static final String USER = "USER";
    public static final String EDITOR = "EDITOR";
    public static final String ADMIN = "ADMIN";

    private AppRoles() {
    }
}

Тепер конфігурація стає менш «рядковою магією» і більш «кодом»:

import com.example.securecontent.security.AppRoles;

// ...
// IDE підказує значення, а ви менше ризикуєте помилитися в імені ролі
.requestMatchers("/api/me/**").hasRole(AppRoles.USER)
.requestMatchers("/api/editor/**").hasRole(AppRoles.EDITOR)
.requestMatchers("/api/admin/**").hasRole(AppRoles.ADMIN)

І аналогічно під час створення користувачів:

import com.example.securecontent.security.AppRoles;
import org.springframework.security.core.userdetails.User;

// Явно перелічуємо ролі, які повинен мати користувач
User.withUsername("eva")
    .password(encoder.encode("eva-pass"))
    .roles(AppRoles.USER, AppRoles.EDITOR)
    .build();

Тут ми робимо важливу річ: фіксуємо словник проєкту. І тоді шанс, що в одному місці роль називається ADMIN, в іншому ROLE_ADMIN, а в третьому (страшно сказати) SuperAdminBecauseWhyNot, стає значно меншим.

7. Межі рольової моделі

У цей момент часто виникає спокуса: «О, ролі зручні, давайте роль на кожну дію! Роль DRAFT_DELETE, роль DRAFT_CREATE, роль PROFILE_WRITE…». І ось ви раптово побудували свою міні-систему ACL на ролях, і вона буде незручною майже всім: і розробникам, і адміністраторам, і майбутньому вам.

Нормальна рольова модель тримається на простому принципі: роль — це блок доступу, а не атомарна дія. Якщо вам потрібно виразити атомарну дію, ви, ймовірно, захочете інший інструмент (authorities). Але зараз достатньо втримати думку: роль не повинна розмножуватися, як кролики, якщо ви не хочете одного дня проводити «інвентаризацію зоопарку».

Для Secure Content Platform API ми навмисно тримаємо ролі мінімальними. USER покриває все, що пов’язано з особистою зоною. EDITOR покриває модерацію. ADMIN покриває керування користувачами. І цього більш ніж достатньо, щоб уже зараз побудувати працюючу матрицю доступу та перевірити, що зони справді відокремлені.

Якщо через кілька днів ви зловите себе на думці «мені потрібно дозволити редактору публікувати, але заборонити відхиляти», це буде сигнал не «додати роль PUBLISHER», а подумати про точкові права. Але сьогодні ми фіксуємо фундамент: три ролі, три зони, без прихованої ієрархії.

8. Типові помилки при ролях USER/EDITOR/ADMIN

Помилка № 1: очікувати, що ADMIN автоматично проходить перевірки USER.
Це найчастіша плутанина. Якщо у вас правило .hasRole("USER"), то користувач повинен мати роль USER. Жодне «старшинство» ролей саме по собі не враховується. Рішення просте: або видавайте адміністратору кілька ролей явно (як ми й робимо в навчальному проєкті), або підключайте ієрархію ролей усвідомлено, коли вже впевнено розумієте механіку.

Помилка № 2: використовувати одну роль на кожен endpoint і перетворити ролі на псевдоправа доступу.
На старті здається, що так буде точніше. Але на практиці це руйнує читабельність і переносить усю складність моделі точкових прав у ролі, які для цього не призначені. Набагато здоровіше тримати ролі великими — як «зонами», а точність додавати іншими інструментами, коли вони справді потрібні.

Помилка № 3: непослідовні імена ролей.
Сьогодні ви пишете EDITOR, завтра CONTENT_EDITOR, післязавтра ROLE_EDITOR, а потім дивуєтеся, що вам потрібен психолог, а не дебагер. Ролі повинні бути короткими, стабільними й однаковими всюди: у користувачах, у SecurityFilterChain, в обговоренні команди та в документації.

Помилка № 4: зберігати імена ролей як рядки в десяти місцях.
Друкарська помилка в рядку — це «баг без червоної лампочки»: проєкт збирається, застосунок стартує, але доступ ламається. Константи (або enum) тут дають величезний виграш у стабільності. Особливо в курсі, де ми хочемо, щоб через місяць ви читали код і не перекладали його в голові щоразу.

Помилка № 5: робити всіх користувачів «суперкористувачами», щоб «не заважало тестувати».
Так ви вбиваєте сам сенс матриці доступу: ви більше не бачите, де зона USER, де EDITOR, де ADMIN, тому що все завжди працює. Навчальний проєкт цінний саме тим, що у вас є сценарні облікові записи, і ви швидко перевіряєте межі ролей на практиці. Якщо весь час ходити під root, то безпека перетворюється на декорацію.

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