ADR-UI-011. Claims и проверка прав на UI
UI скрывает пункты меню, кнопки и маршруты, недоступные по правам. Модель — типизированные string-claims (union) формата подсистема/ресурс/действие с иерархическим wildcard; проверка изолирована в резолвере ClaimService (директива, гард и компоненты спрашивают его). Согласовано с backend (claim icd/ui); UI — витрина прав, граница безопасности — backend.
1. Контекст и постановка задачи
UI должен скрывать пункты меню, кнопки и целые маршруты, недоступные пользователю. Права собираются
на backend (ABAC: функции → claim-ы, ADR_011_AbacAuthorization) и
приходят в профиле как UI-claims (ключ icd/ui, см.
ADR_UI_010_Auth).
Зафиксируем: модель прав на UI; как проверять право в шаблоне/гарде/коде; где живут сущности проверки. UI — витрина прав, а не граница безопасности: реальная проверка — на backend (ADR_SRV_006_AuthImplementation); UI лишь прячет недоступное.
2. Драйверы решения
- Декларативность — проверки видны там, где применяются (шаблон, маршрут).
- Типобезопасность — claim не произвольная строка: автокомплит, find-usages, rename, защита от опечаток.
- Изоляция проверки — решение о доступе в одном резолвере, не размазано по компонентам.
- Согласованность с бэкендом — UI и API используют один набор claim-строк.
3. Рассмотренные варианты
- A. Роли на UI — плохо масштабируются при тонких разрешениях; дублируют серверную логику.
- B. Плоский список string-claims (выбрано) — пользователь имеет набор строк; UI важны только итоговые права. Уточнён типизированным union + резолвером.
- C. RBAC-матрица на UI — избыточно.
4. Решение
Выбран Вариант B: типизированный union claim + резолвер ClaimService.
4.1. Контракт claim
- Формат:
подсистема/ресурс/действие(напр.queue/delete,admin/user/edit). Пункт меню —подсистема/list. - Wildcard (иерархия):
*/*— полный доступ;<подсистема>/*— вся подсистема. - Источник —
UserProfile.claims; backend отдаёт claim-ы ключаicd/uiкак есть.
4.2. Типизированный Claim
Union строковых литералов Claim (src/app/services/claim.service.ts): автокомплит, find-usages,
rename, защита от опечаток.
export type Claim = '*/*' | 'input/list' | 'queue/list' | 'queue/delete' | 'admin/user/edit' | …;
4.3. ClaimService — резолвер (изоляция проверок)
Единственное место принятия решения о доступе; меню/гарды/компоненты спрашивают его, не чекают inline.
@Injectable({ providedIn: 'root' })
export class ClaimService {
setClaims(claims: Claim[]): void { … } // из профиля (AuthStore.loadProfile)
hasClaim(claim: Claim): boolean { … } // */* → <подсистема>/* → точное
hasAnyOfClaims(claims: Claim[]): boolean { … }
}
4.4. Директива *icdHasClaim
Скрывает элемент без нужного claim (семантика ANY), принимает Claim | Claim[], реагирует на
claims-сигнал:
<button *icdHasClaim="'queue/delete'">Удалить</button>
src/app/directives/has-claim.directive.ts.
4.5. Конфиг-управляемые действия (ClaimService.hasClaim в TS)
Структурную директиву можно навесить только на элемент шаблона. Действия из конфиг-массива
(напр. действия строки таблицы) фильтруются в TS через computed:
protected readonly rowActions = computed(() => [
...(this.claims.hasClaim('queue/delete') ? [deleteAction] : []),
]);
Правило: кнопка в шаблоне → директива *icdHasClaim; элемент в конфиг-массиве →
ClaimService.hasClaim() в TS.
4.6. Просмотр без прав (иконка-«глаз»)
Если карточка/дровер служит и для просмотра, и для редактирования: есть …/edit → «карандаш»,
иначе → «глаз» (тот же экран на просмотр). Кнопки сохранения/удаления внутри закрыты своими claim-ами.
Замечание: экран-просмотр всё равно дёргает свои icd/cqrs/query — у функции «только просмотр» они
должны быть, иначе 403.
4.7. Гард ClaimGuard (через route.data)
CanActivateFn; требуемые claim-ы — в route.data.requiredClaims (семантика ANY); при отказе →
/access-denied.
{ path: …, canActivate: [ClaimGuard], data: { requiredClaims: ['queue/list'] satisfies Claim[] } }
4.8. Страница «Доступ запрещён»
/access-denied (сюда уводят ClaimGuard и 403 от backend, см. ADR-UI-010). Даёт выход: «На главную»
и «Войти под другим пользователем» (logout → /login), иначе пользователь без прав заперт.
4.9. Чего не делаем
- Не вычисляем права из ролей на UI (плоский список claims).
- Не поддерживаем negative claims (
'!queue/edit'). - Wildcard — только иерархический (
*/*,<подсистема>/*); произвольных масок нет.
5. Словарь claim-ов (начальный)
Единый источник — union Claim; пополняется по ходу разработки экранов/действий. Подсистемы (1-й
сегмент): input, queue, cards, documents, admin.
Разделы меню (claim пункта/маршрута):
| Меню | claim |
|---|---|
| Ввод данных | input/list |
| Очередь обработки | queue/list |
| Информационные карты | cards/list |
| Документы | documents/list |
| Администрирование | admin/list |
Действия — подсистема/ресурс/действие (напр. input/create — сдать пакет; queue/delete,
queue/forceDelete; cards/edit, cards/export; администрирование — admin/user/{list,edit},
admin/group/{list,edit}, admin/function/list). Наполняются по модулям (Фаза 2).
6. Положительные следствия
- Один формат и одно имя claim на UI и в API.
- Типобезопасность (union) — опечатки ловятся компилятором.
- Декларативные проверки в шаблонах/маршрутах; решение — в одном резолвере.
7. Отрицательные следствия и компромиссы
- Добавление claim требует пополнить union
Claim(это и плюс — явный реестр). - Переименование claim на бэке требует правки union на UI (зато find-usages/rename).
- UI-проверка не заменяет серверную: право показать ≠ право выполнить (граница — backend).
8. Проверка
- Пункты меню скрываются у пользователя без нужного claim — без правки шаблона.
- Прямой переход по URL на защищённую фичу без права →
/access-denied. - В коде нет чтения роли — только
ClaimService.hasClaim().
9. Открытые вопросы / отложено
- Семантика ALL (требовать все claim-ы) — сейчас только ANY; добавим при первом случае.
- Локализация запретов — текст «нужно право X» на
/access-denied(возможный ADR). - Сборка функций из словаря — наполнение функций (наборы claim-ов) по мере стабилизации словаря.