From 1ca2fd18ff07e3567e83c45a5fb46c31975f0734 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 13:53:54 +0400 Subject: [PATCH] =?UTF-8?q?docs(dev):=20=D0=B8=D0=BD=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BA=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D0=B4-=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B0=D1=83=D1=82=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20arm-core=20(?= =?UTF-8?q?#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модель (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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- dev/README.md | 2 + dev/frontend-auth-guide.md | 241 +++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 dev/frontend-auth-guide.md diff --git a/dev/README.md b/dev/README.md index 5211e3f..a1cc0d7 100644 --- a/dev/README.md +++ b/dev/README.md @@ -6,6 +6,8 @@ поднимается только инфраструктура, а сервисы можно запускать из venv (см. «Запуск сервисов из venv»). Для ручного тестирования стенда (установка ПО, запуск, чек-лист сценариев по ролям) — [`qa-guide.md`](qa-guide.md). +Разработчику фронта: как устроены вход и права `arm-core`, какие ручки когда дёргать, семантика 401/403/404 — +[`frontend-auth-guide.md`](frontend-auth-guide.md); контракт API консолидации — [`api-contract-slice-4.md`](api-contract-slice-4.md). ## Предварительные требования diff --git a/dev/frontend-auth-guide.md b/dev/frontend-auth-guide.md new file mode 100644 index 0000000..f33f496 --- /dev/null +++ b/dev/frontend-auth-guide.md @@ -0,0 +1,241 @@ +# Аутентификация и авторизация 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 `; сервер проверяет + подпись и срок, берёт из токена 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": "", "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 аутентификации. Фронт для этого +ничего не делает.