Closes #38 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
247 lines
24 KiB
Markdown
247 lines
24 KiB
Markdown
# Аутентификация и авторизация 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`), по одному на роль (тот же `make seed-dev` создаёт и dev-ГК с ЮЛ под документы из
|
||
`fixtures` — ГК «Русский Бисквит» и три ГК пакета ДКБ, см. `qa-guide.md` раздел 4.2; экраны справочников и консолидации не
|
||
пустые сразу после сида):
|
||
|
||
| 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` → все
|
||
защищённые ручки получают заголовок. Кнопка есть с arm-core #29 (схема `bearerAuth` в `openapi.json`,
|
||
`components.securitySchemes`); на стенде старше этого PR кнопки нет — проверяйте curl. Публичные ручки без замка: `POST /dev/token`,
|
||
`GET /healthcheck`.
|
||
|
||
## 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 аутентификации. Фронт для этого
|
||
ничего не делает.
|