Роли пользователя и доступ к операциям
У обоих fixture-пользователей есть рабочий вход, но только Анна должна читать редакторский кабинет. Добавим роли в проверенные claims и серверное решение о доступе. Важный новый результат — успешная аутентификация Бориса не разрешает редакторскую операцию. Мы не прячем только кнопку в меню: route сам проверяет наличие сессии и роль editor. Это первая полноценная граница полномочий проекта «Пропуск», построенная поверх уже понятного результата личности.
В lesson-07 realm.json добавляет protocol mapper для realm-ролей. Claim называется realm_roles и передаётся в ID Token, access token и userinfo согласно конфигурации. Node использует realm_roles из принятого результата OIDC, сохранённого в локальной сессии. Мы не декодируем случайный bearer-token, присланный посетителем, и не читаем роль из query. Mapper провайдера и проверка приложения должны быть согласованы: одно и то же слово editor не появится автоматически лишь потому, что в базе Keycloak существует такая роль.
Роль и её источник
В realm users Анне назначены reader и editor, Борису только reader. Protocol mapper переводит соответствующие realm-role сведения в определённое поле claims. Имя поля выбрано явно, поэтому серверу не нужно угадывать внутреннюю структуру разных провайдеров. Например, Keycloak может передавать роли в realm_access access token по другому контракту; наш код не должен случайно смешивать эту структуру с ID Token. Мы задаём учебное отображение, затем используем именно его в приложении.
Проверка сначала убеждается, что realm_roles является массивом. Затем ищет точную строку editor. Если поле отсутствует или имеет другой тип, разрешения нет. Не используем поиск подстроки: роль editor-assistant не должна автоматически считаться editor. Неизвестный формат тоже не повышает права. Такой отказ по умолчанию полезен при ошибке mapper: пользователь может войти, но операция остаётся закрытой, пока стороны не согласовали правильную передачу полномочий. Ошибка конфигурации не должна превращаться в общий доступ.
Realm-роли удобны в маленькой лаборатории. При нескольких приложениях часто уместны client-роли или более предметные разрешения, например lesson:publish. Роль editor слишком широка, если одни редакторы могут менять черновики, а другие выпускать их на production. Не следует переносить название роли в бизнес-логику без определения разрешённых действий. В серии защищается чтение кабинета и добавление учебной заявки, а не реальная публикация сайта. Название кнопки или имя маршрута не расширяет её полномочия за пределы этого договора.
Серверное решение
Route /editor сначала проверяет session. При её отсутствии возвращает 401. Если личность есть, но editor нет, возвращает 403. Это различие помогает понимать причину: нужно войти либо вход уже состоялся, но право отсутствует. Для браузерного продукта можно сделать удобные страницы и redirect к login, сохранив серверную семантику. Важнее не превращать любой отказ в редирект, который скрывает сам факт недостаточных полномочий или создаёт бесконечный цикл входа для Бориса.
Ссылка на кабинет показывается всем вошедшим и даже публичной странице в учебной оболочке. Это не нарушает защиту: знание URL не даёт доступ. В реальном интерфейсе ссылку можно скрыть или пояснить, чтобы не создавать неудобство. Но проверка route всё равно обязательна. Посетитель способен открыть адрес напрямую или отправить запрос без меню. Защита на уровне DOM является лишь представлением доступности, а не основанием разрешения серверной операции. Такой пример специально делает различие видимым.
Проверенный набор claims сохранён после OIDC-обмена. Локальная сессия пока живёт пятнадцать минут, и изменение роли в Keycloak не обновляет её мгновенно. Если снять editor у Анны, прежняя Map-запись может продолжить использовать старый набор. Это явная граница политики, а не недостаток строки includes. В одиннадцатом уроке роль пересмотрится при refresh, но и это не обещает мгновенный отзыв. Для критичных операций нужны короткие сроки, запрос актуального решения или полноценная схема событий отзыва.
Почему не берём роль из браузера
Поле формы role=editor, заголовок X-Role или значение localStorage контролируются посетителем, если их принимает приложение без доверенного промежуточного слоя. Даже полезный frontend-state не может заменить результат провайдера. Такой обход особенно соблазнителен при демонстрации интерфейса: кнопки проще переключить одним параметром. Но тогда курс перестанет объяснять единый вход и начнёт учить самостоятельному назначению прав. В нашем проекте role проходит через конфигурацию Keycloak и принятые claims, после чего решение остаётся на сервере.
Сам mapper тоже контролируется администратором провайдера. Если ему дать неверное правило, приложение честно примет неправильный набор полномочий. Значит, доверительная цепочка включает управление realm. Владелец сайта должен различать редактора приложения и администратора identity-сервера. Доступ к настройке mapper или ролей может быть сильнее любой отдельной операции сайта. Мы не делаем браузерный интерфейс управления такими настройками и не выдаём его обычным пользователям лаборатории. Учебный администратор является отдельным доверенным оператором.
Изменение сохранённого realm
Новый realm.json не обновит уже существующую базу автоматически. Для lesson-07 используйте отдельный compose-проект с новым томом либо вручную создайте mapper в текущем realm. Затем начните новую локальную сессию, чтобы получить свежие claims. Просмотр старого профиля из Map после изменения конфигурации не доказывает, что mapper не работает. Нужно различать момент изменения провайдера и момент принятого входа. Эта последовательность повторяет правило жизненного цикла данных, которое пригодится и для refresh.
Полный realm.json ниже фиксирует роли и mapper. При самостоятельном входе Анны ожидается доступ к /editor; Борис должен получить 403, а посетитель без сессии — 401. Эти сценарии при подготовке не выполнялись. Сопоставьте три случая с точными полями claims и сроком локальной записи. Не исправляйте отказ Бориса добавлением editor в query или ослаблением проверки типа. Следующий урок защитит изменение данных: редакторская форма потребует метод POST, правильный Origin и отдельный CSRF-токен сессии.
Разрешение роли нужно привязывать к предметной операции. Чтение кабинета и выпуск материалов на production могут требовать разных правил, даже если сегодня обе кнопки видит одна Анна. В нашем проекте editor разрешает лишь описанный учебный маршрут. Расширение его значения должно сопровождаться новым серверным решением и объяснением последствий, а не одним переименованием ссылки или изменением декоративного класса страницы.
Полный файл realm.json из состояния lesson-07:
{
"realm": "professorweb",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"resetPasswordAllowed": false,
"accessTokenLifespan": 120,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 3600,
"roles": {
"realm": [
{
"name": "editor"
},
{
"name": "reader"
}
]
},
"clients": [
{
"clientId": "propusk",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"fullScopeAllowed": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"implicitFlowEnabled": false,
"serviceAccountsEnabled": false,
"redirectUris": [
"http://localhost:5100/callback"
],
"webOrigins": [],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:5100/"
},
"defaultClientScopes": [
"profile",
"email",
"roles"
],
"protocolMappers": [
{
"name": "realm-roles-for-propusk",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"claim.name": "realm_roles",
"jsonType.label": "String",
"multivalued": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "anna",
"enabled": true,
"firstName": "Анна",
"lastName": "Редактор",
"email": "anna@example.invalid",
"emailVerified": true,
"realmRoles": [
"editor",
"reader"
],
"credentials": [
{
"type": "password",
"value": "Local-Anna-only-2026!",
"temporary": false
}
]
},
{
"username": "boris",
"enabled": true,
"firstName": "Борис",
"lastName": "Читатель",
"email": "boris@example.invalid",
"emailVerified": true,
"realmRoles": [
"reader"
],
"credentials": [
{
"type": "password",
"value": "Local-Boris-only-2026!",
"temporary": false
}
]
}
]
}
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.