# Роли доступа и матрица прав

Ordinis использует **Keycloak realm roles** (claim `realm_access.roles` в JWT) для двух уровней контроля доступа:

1. **Scope ACL** — что пользователь может **читать** (видимость записей и словарей)
2. **Workflow roles** — какие **действия** пользователь может выполнять (approve, publish, reject)

Все роли используют colon-separated namespace `ordinis:<домен>:<действие>`.

## Полная матрица ролей

### Scope ACL (видимость данных)

| Realm role | Видит | Подразумевает |
|---|---|---|
| `ordinis:client:public` | только PUBLIC записи | — |
| `ordinis:client:internal` | PUBLIC + INTERNAL | автоматически включает public |
| `ordinis:client:restricted` | PUBLIC + INTERNAL + RESTRICTED | автоматически включает оба нижних |

Без любой из этих ролей пользователь считается **anonymous** и видит только `PUBLIC` записи (fallback).

> Альтернативные dash-формы (`ordinis-public`, `ordinis-internal`, `ordinis-restricted`) также распознаются для совместимости. Канонические — colon-форма.

### Workflow роли (право на действия)

| Realm role | Право |
|---|---|
| `ordinis:schema:reviewer` | Approve / Запросить правки / Отклонить **schema draft** |
| `ordinis:schema:publisher` | Publish approved schema draft → live |
| `ordinis:record:reviewer` | Approve / Reject **record draft** (Approval Workflow v2) |
| `ordinis:admin` | **Super-role** — автоматически разрешает любой workflow action. Convenience для small teams: assign one role вместо трёх. |

Backend проверяет роль перед каждым действием. Без роли — `403 forbidden_role` с сообщением о требуемой роли. Frontend дополнительно скрывает кнопки, для которых нет роли.

> **JWT claim path:** на staging/prod backend читает роли из `resource_access.ordinis.roles` (client-scoped roles в Keycloak `ordinis` client). Default fallback — `realm_access.roles`. Настраивается через `ORDINIS_AUTH_ROLES_CLAIM` env var.

> **Maker side** (создание / submit / withdraw draft'а) специальной ролью не gated — любой authenticated user с required scope может создать draft. Backend защищает invariant maker-checker через `403 self_approve_forbidden` (один и тот же user не может approve свой draft).

## Полная матрица действий

| Действие | Endpoint | Scope требуется | Workflow роль требуется |
|---|---|---|---|
| Просмотр PUBLIC dict | `GET /api/v1/dictionaries` | public+ | — |
| Просмотр INTERNAL/RESTRICTED dict | `GET /api/v1/dictionaries` | соответствующий scope | — |
| Создание dict | `POST /api/v1/dictionaries` | соответствующий scope | — |
| Редактирование dict schema (через draft) | `POST /api/v1/dictionaries/{name}/drafts` | соответствующий scope | — (maker) |
| Submit schema draft на ревью | `POST .../drafts/{id}/review` | соответствующий scope | — (maker) |
| **Approve / Request changes / Reject schema draft** | `POST .../drafts/{id}/decision` | соответствующий scope | `ordinis:schema:reviewer` |
| **Publish approved schema draft** | `POST .../drafts/{id}/publish` | соответствующий scope | `ordinis:schema:publisher` |
| Withdraw собственный schema draft | `POST .../drafts/{id}/withdraw` | соответствующий scope | — (maker self) |
| Создание record draft | `POST .../records/.../drafts` | соответствующий scope | — (maker) |
| **Approve record draft** | `POST /api/v1/drafts/{id}/approve` | соответствующий scope | `ordinis:record:reviewer` |
| **Reject record draft** | `POST /api/v1/drafts/{id}/reject` | соответствующий scope | `ordinis:record:reviewer` |
| Просмотр webhook subscriptions | `GET /api/v1/admin/webhooks/subscriptions` | `internal` или `restricted` | — |
| Просмотр audit log | `GET /api/v1/admin/audit` | соответствующий scope | — |

## Типичные профили пользователей

### Только чтение (consumer, integrator)
- `ordinis:client:public`

Видит публичные справочники, не может ничего менять.

### Maker (контент-редактор)
- `ordinis:client:internal` (видит данные своей области)

Создаёт и редактирует справочники, записи, schema drafts, submit'ит их на ревью. **Не может** approve / publish свои собственные действия (maker-checker).

### Reviewer
- `ordinis:client:restricted`
- `ordinis:schema:reviewer`
- `ordinis:record:reviewer`

Может всё что maker, плюс approve / reject чужих drafts. Publish — отдельной ролью.

### Publisher
- `ordinis:client:restricted`
- `ordinis:schema:reviewer` (опционально)
- `ordinis:schema:publisher`

Финальный gatekeeper для production публикации схем. Обычно более узкая группа чем reviewers.

### Admin (полный доступ)

**Простой путь** — single role `ordinis:admin` (super-role): backend и frontend оба recognize её как override для любого workflow check'а. Достаточно одного назначения.

**Канонический путь (composite в Keycloak)** — `ordinis:admin` как composite, агрегирующий:
- `ordinis:client:restricted`
- `ordinis:schema:reviewer`
- `ordinis:schema:publisher`
- `ordinis:record:reviewer`

Composite-роль в Keycloak автоматически expands в JWT — token содержит все включённые роли. Это работает и без super-role override.

## Настройка в Keycloak

### Создание ролей

1. Открой **Keycloak Admin Console** → realm `nstart` → **Realm roles**.
2. Нажми **Create role** и создай по очереди:
   - `ordinis:client:public`
   - `ordinis:client:internal`
   - `ordinis:client:restricted`
   - `ordinis:schema:reviewer`
   - `ordinis:schema:publisher`
   - `ordinis:record:reviewer`

### Composite role `ordinis:admin` (опционально, но удобно)

3. **Create role** → `ordinis:admin`.
4. На вкладке роли → **Action** → **Add associated roles** → выбери все 6 ролей выше → **Assign**.

После этого можно назначать одну роль `ordinis:admin` users'ам, и они автоматически получают все права.

> **Заметка про token mapper:** composite role в Keycloak **разворачивается в JWT** только если есть соответствующий mapper. У client `ordinis` в realm `nstart` должен быть mapper типа **User Realm Role** или **User Client Role** с `Multivalued: ON` и `Add to access token: ON`. Без mapper'а composite роль будет на user'е, но в JWT попадёт только сама `ordinis:admin`, а её components (`ordinis:schema:reviewer` и т.д.) — нет.
>
> Backend handles это двумя путями:
> 1. **Mapper настроен** (рекомендуется): JWT содержит all 6 expanded roles → `WorkflowRoles.hasRole(specific)` находит её напрямую.
> 2. **Mapper не настроен** (default Keycloak install): JWT содержит только `ordinis:admin` → `WorkflowRoles.hasRole(role)` всё равно проходит через super-role override (`roles.contains(ADMIN)`).
>
> Super-role override — fallback. Mapper — proper solution. Если admin user'ов больше 5 — настройте mapper, чтобы scope ACL и audit log видели правильные конкретные роли.

### Per-dictionary минимальная роль ревьюера (Phase 2)

В `DictionaryEditorDialog` есть поле **«Минимальная роль ревьюера (опционально)»** (`approvalMinRole`). Если задано (e.g. `ordinis:restricted`) — backend требует чтобы reviewer держал эту роль **поверх** стандартной `ordinis:record:reviewer` / `ordinis:schema:reviewer` / `ordinis:schema:publisher`.

Use case: критичный справочник (PII, госуд. реестр) — `approvalMinRole = ordinis:restricted` гарантирует что approve может только user с restricted-scope ролью, даже если у него есть reviewer на других dict'ах. `ordinis:admin` super-role проходит всегда.

Поле сохраняется в DB как plain string. Backend (`DraftService.requireDictMinRoleIfSet`, `SchemaDraftService.requireDictMinRoleIfSet`) делает `workflowRoles.requireRole(approvalMinRole)` перед состоянием перехода.

### Назначение пользователю

1. **Users** → найди user'а → **Role mapping**.
2. **Assign role** → отметь нужные роли (или `ordinis:admin` для админов).

### Применение

Пользователю нужно **logout / login** в Ordinis UI, чтобы получить новый JWT с обновлённым `realm_access.roles` claim'ом.

## Диагностика

### «Не могу нажать Approve — кнопка скрыта»

UI скрывает кнопку если у user'а нет нужной workflow роли. Проверить:

1. F12 → Application → Session Storage → `oidc.user:...`.
2. В JSON найти `profile.realm_access.roles`.
3. Должна быть `ordinis:schema:reviewer` (для schema) или `ordinis:record:reviewer` (для record).

Если роли нет — назначить в Keycloak и сделать logout/login.

### «403 forbidden_role» от backend

Backend вернул 403 с кодом `forbidden_role`. Сообщение содержит требуемую роль. Назначь её в Keycloak и refresh JWT.

### «403 self_approve_forbidden»

Пользователь пытается approve свой собственный draft. Это **invariant maker-checker** — backend защищает от self-approve. Нужен другой reviewer.

### «404 / пустая страница на /webhooks»

Webhook subscriptions требуют `internal` или `restricted` scope. Назначь `ordinis:client:internal` минимум.

## История

- **Phase 1a/1b/1c** (до 2026-05-12) — backend гейтил только maker-checker, любой authenticated мог review.
- **Phase 1d** (2026-05-13) — backend проверяет workflow роли; frontend скрывает кнопки. Migration require: создать роли в Keycloak и assign actual reviewer-users до или одновременно с deploy.
- **Phase 2** (2026-05-13, vечером) — per-dict `approvalMinRole` enforced. Поле существовало с миграции 0019, но backend его игнорировал (UI hint прямо говорил "(Phase 2 — enforcement TBD)"). Теперь required gate в approve/reject/decide/publish.
