arm-ka-docs/dev/frontend-auth-guide.md
Andrey Epifancev 1ca2fd18ff docs(dev): инструкция для фронтенд-разработчика по аутентификации и авторизации arm-core (#28)
Модель (JWT без сессий, права как данные, порядок проверок право → видимость → статус), режимы dev/прод,
POST /dev/token и GET /me с семантикой 401/403/404, сводка прав по маршрутам, каталог прав и ролей,
policy-слой, эталонный клиент web/src/{api,auth}, чек-лист, проверка руками, открытые вопросы (OQ-54).
Ссылка добавлена в dev/README.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 13:53:54 +04:00

242 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Аутентификация и авторизация arm-core: инструкция для разработчика фронта
Источник — код `arm-core` на `main` (06.10.2026): `api/src/core/auth.py`, `api/src/api/dependencies/auth.py`,
`api/src/api/endpoints/{me,dev_auth}.py`, `api/src/domain/policy.py`, эталонный клиент `web/src/{api,auth}/`; спека —
`arm-core/openspec/specs/authn-authz/spec.md`; роли и права — `context/05-domain-rules.md` §10. Здесь — только то,
что нужно, чтобы писать клиент. Правила предметной области и контракты экранов — в `api-contract-slice-4.md` и
`context/10-user-journey.md`.
## 1. Модель в трёх предложениях
1. **Сессий на сервере нет.** Каждый запрос к `/api/v1/*` несёт `Authorization: Bearer <JWT>`; сервер проверяет
подпись и срок, берёт из токена email, находит пользователя в `app_user` и его права. Нет cookie, нет refresh,
нет logout-ручки: «выйти» = забыть токен на клиенте.
2. **Права — данные, роли — нет.** Что можно пользователю, решает множество кодов прав из `GET /me → permissions`
(объединение прав всех его ролей). Код роли (`KA_RF`, `ADMIN`…) в бизнес-логике сервера **не проверяется** и
фронтом использоваться для ветвления не должен: матрица «роль → право» — черновик, администратор правит её в UI.
3. **Клиент только прячет, сервер решает.** Скрывать пункты меню и кнопки по `permissions` можно и нужно, но любой
запрос может вернуть 401/403/404/409 — клиент обязан их обрабатывать, а не считать, что «кнопка была видна, значит
можно».
Порядок проверок на любом защищённом маршруте: **право (403) → видимость объекта (404) → статус/блокировка (409) →
операция**. Первые два — предмет этой инструкции.
## 2. Два режима получения токена
| | Разработка (`DEBUG=true`, `AUTH_DEV_ISSUER=true`) | Прод (`DEBUG=false`) |
|---|---|---|
| Откуда токен | `POST /api/v1/dev/token` по email, без пароля | SSO РАИСА (Keycloak); SPA получает JWT от SSO и подставляет в заголовок |
| Подпись | HS256, общий секрет `AUTH_DEV_SECRET` | RS256/ES256 по `AUTH_JWKS_URL` или `AUTH_PUBLIC_KEY` |
| Срок | 8 часов, продления нет | задаёт РАИСА |
| Что в токене | `sub` = email, `email` = email, `iat`, `exp` | `sub`, `exp`, email в claim `AUTH_EMAIL_CLAIM` (по умолчанию `email`) |
| Ручка `/dev/token` | есть | **не существует** (404 `{"detail":"Not Found"}`), dev-токены отвергаются 401 |
| CORS | `*` без credentials | только источники из `CORS_ORIGINS`; токен в заголовке, cookie не нужны |
| `/docs`, `/openapi.json` | есть | 404 |
**Параметры JWT РАИСА неизвестны** (OQ-54 в `context/07-open-questions.md`): URL JWKS, `iss`, `aud`, имя claim с
email, как именно SPA получит токен (в старом АРМ — Keycloak через `REACT_APP_KEYCLOAK_URL/REALM/CLIENT_ID`, в
`web/package.json` подключён `raisa-fastapi-protected-web` как optionalDependency). Поэтому слой «получить токен» на
фронте держите отдельно от слоя «использовать токен»: второй уже не изменится, первый в проде заменится целиком.
## 3. Ручки
Префикс везде `/api/v1`. Форматы тела ошибки `{"detail": …}` — три: **строка** (401/403/404 «Not Found»/405/413),
**объект** `{code, message, …}` (доменные ошибки, в том числе 404 `NOT_FOUND` и 409), **массив** pydantic
(`[{loc, msg, type}]`, 422 валидации тела). Эталонный разбор — `web/src/api/client.ts:parseErrorBody`.
### 3.1 `POST /dev/token` — dev-токен (только разработка)
Без авторизации. Тело `{"email": "ka_rf@armka.local"}` (регистр и пробелы по краям не важны).
| Ответ | Когда |
|---|---|
| 200 `{"access_token": "<JWT>", "token_type": "bearer"}` | активный пользователь с таким email есть; в журнал пишется `LOGIN` |
| 404 строка `пользователь не найден или отключён` | email не найден или `is_active = false` |
| 409 строка `неоднозначная учётная запись` | в БД несколько пользователей с email, различающимся регистром |
| 422 массив | пустой email |
| 404 `{"detail":"Not Found"}` | ручка выключена (`DEBUG=false` или `AUTH_DEV_ISSUER=false`) — так выглядит прод |
Dev-пользователи (`make seed-dev`), по одному на роль:
| Email | Роль | Филиал | Видимость |
|---|---|---|---|
| `admin@armka.local` | ADMIN | Головной офис | всё; единственный с `USERS_ADMIN` и `DIRECTORY_ADMIN` |
| `ka_go@armka.local` | KA_GO | Головной офис | всё, правки |
| `curator_go@armka.local` | CURATOR_GO | Головной офис | всё, правки справочников |
| `expert@armka.local` | EXPERT | Головной офис | всё, только чтение |
| `ka_rf@armka.local` | KA_RF | Ростовский РФ | свой филиал, правки |
| `rka@armka.local` | RKA | Ростовский РФ | свой филиал |
| `client_manager@armka.local` | CLIENT_MANAGER | Краснодарский РФ | свой филиал, чтение + загрузка документов |
Для проверок разными ролями берите несколько токенов — они независимы, живут в разных вкладках.
### 3.2 `GET /me` — кто я и что мне можно
Нужен любой действующий токен; прав не требует. Единственная ручка, которую вызывают «просто с токеном».
```json
{
"id": "uuid",
"email": "ka_rf@armka.local",
"full_name": "Dev: Аналитик РФ",
"regional_office": { "id": "uuid", "name": "Ростовский региональный филиал", "is_head_office": false },
"business_segment": "DKB",
"roles": ["KA_RF"],
"permissions": ["CONSOLIDATION_EDIT", "CONSOLIDATION_VIEW", "DIRECTORY_EDIT", "…"]
}
```
`roles` и `permissions` — коды, отсортированы по алфавиту. `business_segment` может быть `null`.
`regional_office.is_head_office` — единственное, что фронту полезно знать о видимости: пользователь ГО видит всё,
пользователь филиала — только ГК своего филиала (деталей клиент не вычисляет, см. §5).
**Когда дёргать.**
- Сразу после получения токена (dev-форма или SSO) — чтобы построить меню и контекст пользователя.
- При старте приложения, если токен уже сохранён (перезагрузка страницы): ответ 200 — пользователь «вошёл», 401 —
токен протух, показать вход.
- **Не** перечитывается автоматически после того, как администратор поменял роли или права: пользователь увидит
изменения после перезагрузки страницы. Это известное ограничение, не баг клиента.
**Ответы `/me` и что они значат.**
| Ответ | Смысл | Что делать клиенту |
|---|---|---|
| 200 | вошёл | сохранить профиль в контексте, строить UI по `permissions` |
| 401 строка (есть `WWW-Authenticate: Bearer`) | токена нет, он просрочен, подпись не прошла, «аутентификация не настроена» на сервере | сбросить токен, показать вход |
| 403 строка `пользователь не зарегистрирован в АРМ` / `учётная запись отключена` / `неоднозначная учётная запись` | **токен валиден, но этого человека нет в АРМ** | показать «нет доступа в АРМ, обратитесь к администратору»; повторный вход не поможет, токен сбрасывать не обязательно |
### 3.3 Всё остальное — защищённые маршруты
Каждый маршрут объявляет одно право. Полные таблицы «метод и путь → право» — `arm-core/README.md` (разделы
«API справочников», «API файлов и документов», «Консолидация»), для Среза 4 — `api-contract-slice-4.md` §2. Сводка:
| Область | Чтение | Правки | Прочее |
|---|---|---|---|
| `/directories/regional-offices`, `/company-groups`, `/related-groups`, `/legal-entities` | `DIRECTORY_VIEW` | `DIRECTORY_EDIT` | удаление ГСК/алиаса — `DIRECTORY_DELETE`; `POST /directories/import` — `DIRECTORY_ADMIN` |
| `/legal-entities/{id}/files`, `/files/{id}`, `/documents/{id}` | `DOCUMENT_VIEW` | `DOCUMENT_EDIT` (загрузка, удаление, подтверждение) | |
| `/consolidations`, `/company-groups/{id}/consolidations`, `/consolidations/versions/{id}/*` | `CONSOLIDATION_VIEW` | `CONSOLIDATION_EDIT` (создание, блокировка, статус, периметр, сопоставление) | экспорт xlsx — `CONSOLIDATION_VIEW` |
| `/admin/users`, `/admin/roles`, `/admin/permissions` | `USERS_ADMIN` | `USERS_ADMIN` | проверка права — до разбора тела: без права 403 всегда |
| `/me` | любой пользователь | — | |
| `/healthcheck` (без префикса `/api/v1`) | без токена | — | живость сервера, не признак «вошёл» |
Права `KFO_*`, `EX_VIEW`, `EXPORT`, `AUDIT_VIEW` в каталоге есть, но маршрутов под них пока нет (Срезы 5–7).
Ответы, общие для всех защищённых маршрутов:
| Ответ | Смысл | Что делать клиенту |
|---|---|---|
| 401 строка | как у `/me` | сбросить токен, экран входа — **глобально**, одним обработчиком в HTTP-клиенте |
| 403 строка `недостаточно прав: требуется <КОД>` | права нет | показать «нет прав»; обычно кнопка не должна была показываться — проверить `permissions` |
| 403 строка про «не зарегистрирован / отключена / неоднозначная» | пользователя удалили или отключили, пока токен жив | как у `/me`: «нет доступа в АРМ» |
| 404 объект `{"code": "NOT_FOUND", …}` | объекта нет **или он вне видимости пользователя** — ответы неразличимы по статусу и коду | показать «не найдено»; не ветвиться по тексту `message` |
| 405 строка, заголовок `Allow` | неверный метод на существующий маршрут | ошибка клиента |
| 404 `{"detail":"Not Found"}` строкой | маршрута нет (в том числе `/dev/token` в проде) | ошибка клиента или выключенный флаг |
| 409 / 422 объект | доменная ошибка (`LOCK_REQUIRED`, `LOCKED_BY_OTHER`, `VERSION_NOT_EDITABLE`, `LAST_ADMIN`, …) | ветвиться по `code`; каталог — README «Ошибки API» |
Как отличить два вида 403 программно: оба — строки без кода. Надёжный признак — **контекст запроса**: 403 на `/me`
бывает только аутентификационным; на остальных маршрутах 403 почти всегда «недостаточно прав». Если нужно различать
точнее, при 403 с текстом не про право повторите `GET /me`: он вернёт тот же 403 аутентификации.
## 4. Права и роли: что есть сейчас
Каталог прав (16 кодов, `api/seed/core/permissions.yaml`; формат `<ОБЪЕКТ>_<ДЕЙСТВИЕ>`):
| Объект | Коды |
|---|---|
| DIRECTORY | `DIRECTORY_VIEW`, `DIRECTORY_EDIT`, `DIRECTORY_DELETE`, `DIRECTORY_ADMIN` (импорт) |
| DOCUMENT | `DOCUMENT_VIEW`, `DOCUMENT_EDIT` |
| CONSOLIDATION | `CONSOLIDATION_VIEW`, `CONSOLIDATION_EDIT` |
| KFO | `KFO_VIEW`, `KFO_EXPORT`, `KFO_APPROVE`, `KFO_VERIFY` |
| EX | `EX_VIEW` |
| EXPORT | `EXPORT` |
| USERS | `USERS_ADMIN` |
| AUDIT | `AUDIT_VIEW` |
Роли (7 системных, `roles.yaml`): `KA_RF`, `KA_GO`, `RKA`, `CLIENT_MANAGER`, `CURATOR_GO`, `EXPERT`, `ADMIN`.
Стартовая матрица «роль → права» — `role_permissions.yaml` и таблица §10 `05-domain-rules.md`. Новых прав и ролей
через API не создать (422 `UNKNOWN_PERMISSION` / `UNKNOWN_ROLE`); права роли меняет `PUT /admin/roles/{code}/permissions`,
роли пользователя — `PUT /admin/users/{id}/roles`.
Правила для фронта:
- Константы кодов прав — в одном модуле (`web/src/auth/permissions.ts`); завести код в клиенте можно только если он есть
в `permissions.yaml`. Типы `PermissionObject`/`PermissionAction` — `web/src/api/types.ts`.
- Элемент UI привязывается к **праву**, а не к роли: «кнопка «Взять в работу» видна при `CONSOLIDATION_EDIT`», а не
«видна КА». Пользователь может иметь несколько ролей; `permissions` — уже объединение.
- `roles` из `/me` — только для отображения («Роль: Кредитный аналитик РФ») и экрана администратора.
## 5. Чего клиент не вычисляет: policy-слой
Поверх прав сервер применяет правила видимости и редактируемости (`domain/policy.py`), которые фронт **не дублирует**:
- **Видимость по филиалу.** Пользователь ГО (`regional_office.is_head_office = true`) видит все ГК, ЮЛ и версии;
пользователь филиала — ГК, закреплённые за его филиалом, их ЮЛ и ГСК, монозаёмщиков своего филиала. Списки
уже отфильтрованы сервером; чужой объект по id — 404 `NOT_FOUND`, как несуществующий.
- **Редактируемость версии консолидации.** Правки — только в статусах «в работе» (`INITIATED`,
`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`) и при своей активной блокировке; иначе 409 `VERSION_NOT_EDITABLE`,
`LOCK_REQUIRED`, `LOCKED_BY_OTHER`. Для UI признаки есть в карточке версии (`VersionCard.is_in_work`, `lock`),
см. `api-contract-slice-4.md`.
- **Зона обслуживания** (`business_segment`) пока не фильтрует (OQ-86).
## 6. Как это сделано в эталонном клиенте `web/`
Повторять не обязательно, но контракт с бэкендом здесь уже отработан.
| Файл | Что делает |
|---|---|
| `src/api/client.ts` | `apiFetch`: база `${REACT_APP_API_URL}/api/v1`, ставит `Authorization: Bearer` из `token.ts`; на **401 сбрасывает токен и шлёт событие `armka:unauthorized` на `window`**; парсит три формы `detail` в `ApiError {status, code, message}`; сетевую ошибку отдаёт как `ApiError(0, 'NETWORK')`; 204 и пустое тело → `undefined` |
| `src/auth/token.ts` | токен в `sessionStorage` (ключ `armka.token`) с запасом в памяти, если хранилище недоступно. Вкладка закрылась — токен пропал; новая вкладка — новый вход (решение D-9) |
| `src/auth/useMe.ts` | загружает `/me`, если токен есть; слушает `armka:unauthorized` и сбрасывает профиль с текстом «Сессия истекла» |
| `src/auth/AuthGate.tsx` | корневая обёртка: `loading` → спиннер; нет профиля → экран входа; есть → `MeContext.Provider` |
| `src/auth/MeContext.ts` | `useCurrentUser()` — профиль из контекста (без повторных запросов `/me`); вне `AuthGate` бросает |
| `src/auth/DevLogin.tsx` | форма «Вход для разработки» → `POST /dev/token` → `setToken` → `reload`. В production-сборке **вырезана** по `process.env.NODE_ENV`; вместо неё заглушка «Вход выполняется через РАИСА» с кнопкой «Повторить» |
| `src/auth/permissions.ts` | константы кодов прав для UI |
| `src/widgets/Sidebar/Sidebar.tsx` | пункты меню с полем `permission` фильтруются по `me.permissions` |
| `src/layouts/Admin.tsx` | без `USERS_ADMIN` показывает «Нет доступа» и **не делает запросов** к `/admin/*` |
Окружение фронта: `REACT_APP_API_URL` (адрес бэкенда, в dev `http://localhost:8000`) и `REACT_APP_ROOT_PATH`
(= `ROOT_PATH` бэкенда, в dev `/`); обе обязательны при `npm run start`. Для проверки входа используйте
`npm run start`, а не `npm run build`: в production-сборке dev-формы нет.
## 7. Чек-лист для нового экрана или клиента
1. Все запросы — через единый HTTP-клиент, который сам подставляет `Bearer` и глобально обрабатывает 401.
2. После получения токена и при старте с сохранённым токеном — один `GET /me`; профиль — в контексте, не в каждом экране.
3. Видимость элементов — по `me.permissions`, константы — из одного модуля; роли не проверять.
4. 403 — показать причину из `detail`; 404 `NOT_FOUND` — «не найдено», без ветвления по тексту; 409/422 — по `code`.
5. Деньги в ответах — строки `"100.00"`, не парсить во `float` (общее правило контрактов).
6. Токен не писать в URL, `localStorage` и логи; на сервере он тоже не логируется.
7. Слой получения токена (dev-форма сейчас, SSO потом) изолирован от слоя использования.
## 8. Проверка руками
```bash
# токен
TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \
-d '{"email":"ka_rf@armka.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
# профиль
curl -s localhost:8000/api/v1/me -H "Authorization: Bearer $TOKEN"
# 401: без заголовка
curl -s -i localhost:8000/api/v1/me | head -5
# 403 по праву: KA_RF без USERS_ADMIN
curl -s localhost:8000/api/v1/admin/users -H "Authorization: Bearer $TOKEN"
# 404 dev-ручки в проде — так же выглядит и несуществующий путь
curl -s localhost:8000/api/v1/no-such-route -H "Authorization: Bearer $TOKEN"
```
В Swagger (`/docs`, только при `DEBUG=true`): **Authorize** → вставить токен без слова `Bearer`.
## 9. Открытые вопросы, которые затронут фронт
| # | Вопрос | Что менять на фронте, когда решится |
|---|---|---|
| OQ-54 | Параметры JWT РАИСА и способ получения токена SPA (Keycloak? `raisa-fastapi-protected-web`?) | только слой получения токена: заменить `DevLogin`/`ProdNotice` на реальный SSO-поток; редирект на вход при 401 |
| — | Обновление токена (refresh) и его срок в проде | если SSO даст refresh — обновлять в HTTP-клиенте перед 401 |
| — | Выход (`LOGOUT` в аудит не пишется, сессий нет) | кнопка «Выйти» = `clearToken()` + редирект на SSO logout, если он будет |
| — | Перечитывание `/me` после смены ролей администратором | сейчас — перезагрузка страницы |
Аудит входа (OQ-64, закрыт): сервер пишет `LOGIN` при выдаче dev-токена и при первом успешном запросе пользователя
после старта процесса, `LOGIN_FAILED` — при 401 с предъявленным токеном и при 403 аутентификации. Фронт для этого
ничего не делает.