From a8a07fe019d0797e6b47811e60b3e17a8189f141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 4 Jun 2026 13:53:44 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20IAM=20API=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20+=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=A2=D0=97=20+?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/auth.js: fetchIamUser(), switchProfile(), userFromPayload(iamData) - src/config.js: IAM_API_URL из env - src/routes/oidc.js: обогащение сессии через IAM (с fallback на JWT) - ui/routes/auth.js: обогащение при токен-логине - ui/routes/entries.js: switchTo через POST /switch-profile - src/api/routes/entries.js: resolveCompany через profiles[] - views/index.ejs: переключатель компаний с названиями из profiles - .env.example: IAM_API_URL - docs: обновлены ТЗ-реализация.md, ТЗ-плюс.md, добавлен iam-integration.md - tests: api-crud.sh (14 тестов CRUD + валидации) - .gitignore: исключены .env.test, DEPLOY-TESTING.md --- .env.example | 5 + .gitignore | 6 + docs/iam-integration.md | 480 +++++++++++++++++++++++++++++++++++ docs/legacy-ТЗ-плюс.md | 161 ++++++++++++ docs/legacy-ТЗ-реализация.md | 242 ++++++++++++++++++ docs/ТЗ-плюс.md | 274 +++++++++++--------- docs/ТЗ-реализация.md | 18 +- src/api/routes/entries.js | 15 +- src/auth.js | 112 +++++++- src/config.js | 7 +- src/routes/oidc.js | 57 +++-- tests/api-crud.sh | 99 ++++++++ ui/routes/auth.js | 40 ++- ui/routes/entries.js | 34 ++- views/index.ejs | 18 +- 15 files changed, 1404 insertions(+), 164 deletions(-) create mode 100644 docs/iam-integration.md create mode 100644 docs/legacy-ТЗ-плюс.md create mode 100644 docs/legacy-ТЗ-реализация.md create mode 100755 tests/api-crud.sh diff --git a/.env.example b/.env.example index a870698..6d990b7 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ PORT=3001 # ── Администратор (clientId) ── ADMIN_CLIENT_ID=WZ01112 +# ── IAM API (сервис авторизации Nubes) ── +# Используется для получения списка компаний пользователя (GET /api/v1/auth/user) +# и переключения активной компании (POST /api/v1/user/switch-profile). +IAM_API_URL=https://auth-api.ngcloud.ru + # ── Keycloak / OIDC (ЗАПОЛНИТЬ при DEV_MODE=false) ── # KC_CLIENT_ID= # ID зарегистрированного OIDC-клиента в Keycloak # KC_CLIENT_SECRET= # секрет клиента diff --git a/.gitignore b/.gitignore index 5ed10d6..5f8e0ee 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ docs/agent-opinions.md # Логи тестов test-results/ + +# Тестовые окружения (только для внутреннего использования) +.env.test +.env.production +DEPLOY-TESTING.md +tests/comprehensive.js diff --git a/docs/iam-integration.md b/docs/iam-integration.md new file mode 100644 index 0000000..f82746f --- /dev/null +++ b/docs/iam-integration.md @@ -0,0 +1,480 @@ +# IAM API — интеграция с ipwhitelist-app + +> Дата: 2026-06-04 +> Исследование: проверка текущей интеграции и план доработок + +--- + +## 1. Что такое IAM API + +IAM (Identity & Access Management) — единый сервис авторизации экосистемы Nubes. Через него работают Личный Кабинет (ЛК) и все смежные сервисы. + +**Три стенда:** + +| Стенд | URL | +|---|---| +| Dev | `https://auth-api-dev.ngcloud.ru` | +| Test | `https://auth-api-test.ngcloud.ru` | +| Prod | `https://auth-api.ngcloud.ru` | + +**Swagger-документация:** `https://auth-api-dev.ngcloud.ru/api/v1/documentation/` + +--- + +## 2. Исходные данные (Elma CRM) + +Изначальный источник — CRM Elma. Две сущности: + +- **Контакт** (contact) — человек, пользователь +- **Компания** (company) — организация + +Отношение **не 1:1**. Контакт может представлять несколько компаний. Это нормальная ситуация. + +В токене IAM, который пользователь получает в ЛК, зашита **основная** компания. Но через API IAM можно получить **все** доступные пользователю компании и узнать, какая из них **активна в данный момент**. + +Смежные сервисы ходят в IAM API для проверки, что услуга выпускается для правильной компании. + +--- + +## 3. Ключевые эндпоинты IAM API + +### 3.1. `GET /api/v1/auth/user` — информация о пользователе + +**Назначение:** получение полной информации о текущем пользователе, включая список всех доступных профилей (компаний) и флаг активного профиля. + +**Авторизация:** `Authorization: Bearer ` + +**Реальный ответ** (тестовый пользователь `tazetdinovn@gmail.com`, компания `naeel_test`, ClientID `WZ03709`): + +```json +{ + "impersonation": { + "is_impersonated": false + }, + "isPortal": false, + "needChangePassword": false, + "owner": false, + "permissions": { + "can_write": false, + "has_write_permissions": false, + "is_impersonating": false, + "read_only_mode_enabled": false, + "reason": "insufficient_permissions" + }, + "privileges": null, + "profiles": [ + { + "id": 2833, + "client_id": "WZ03709", + "company_id": "019cc24a-727e-740f-b407-bec79dab4162", + "company_name": "naeel_test", + "company_numeric_id": 2645, + "is_active_profile": true + } + ], + "roles": [ + { + "created_at": "0001-01-01T00:00:00Z", + "role_id": 1, + "role_name": "Пользователь", + "user_uuid": "019cc268-6c6a-781e-8613-4bed4ec7cd20" + } + ], + "sessionId": "", + "userId": "019cc268-6c6a-781e-8613-4bed4ec7cd20", + "userInfo": { + "accounts": [], + "avatar": [""], + "clientID": "WZ03709", + "company": "naeel_test", + "companyId": "019cc24a-727e-740f-b407-bec79dab4162", + "companyManager": "", + "companyNumericId": 2645, + "contactId": "019cc268-6c6a-781e-8613-4bed4ec7cd20", + "currentSupportLevel": "", + "elmaUserId": 17193, + "email": "tazetdinovn@gmail.com", + "externalUser": true, + "fio": { + "fullName": "Тазетдинов Наиль Фаритович", + "name": "Наиль", + "secondName": "Фаритович", + "surname": "Тазетдинов" + }, + "groupIds": null, + "integration": { "serviceId": "" }, + "isAdmin": false, + "login": "", + "mobilePhone": [], + "position": "", + "userId": "019cc268-6c6a-781e-8613-4bed4ec7cd20" + } +} +``` + +#### Ключевые поля для интеграции: + +| Поле | Тип | Описание | +|---|---|---| +| `profiles[]` | array | **Все** доступные пользователю профили (компании) | +| `profiles[].id` | integer | ID профиля для `POST /switch-profile` | +| `profiles[].client_id` | string | WZ-код компании | +| `profiles[].company_id` | string | UUID компании | +| `profiles[].company_name` | string | Название компании | +| `profiles[].company_numeric_id` | integer | Числовой ID компании | +| `profiles[].is_active_profile` | boolean | **Какой профиль активен в данный момент** | +| `userInfo.clientID` | string | WZ-код **активной** компании | +| `userInfo.company` | string | Название активной компании | +| `userInfo.companyId` | string | UUID активной компании | +| `userInfo.companyNumericId` | integer | Числовой ID активной компании | +| `userInfo.email` | string | Email пользователя | +| `userInfo.isAdmin` | boolean | Флаг администратора | +| `userInfo.fio` | object | ФИО пользователя | +| `userInfo.contactId` | string | UUID контакта в Elma | +| `userId` | string | UUID пользователя | + +#### Пример для пользователя с несколькими компаниями: + +```json +"profiles": [ + { + "id": 2833, + "client_id": "WZ03709", + "company_name": "naeel_test", + "is_active_profile": true // ← активная + }, + { + "id": 3120, + "client_id": "WZ12345", + "company_name": "Другая Компания", + "is_active_profile": false // ← неактивная + } +] +``` + +### 3.2. `POST /api/v1/user/switch-profile` — переключение компании + +**Назначение:** переключить активный профиль пользователя на другую компанию. + +**Тело запроса:** +```json +{ + "profile_id": 3120 +} +``` +или +```json +{ + "company_id": "uuid-компании" +} +``` + +**Ответ:** +```json +{ + "success": true, + "active_profile": { + "id": 3120, + "company_id": "uuid", + "company_name": "Другая Компания", + "email": "user@example.com", + "full_name": "ФИО", + "contact_id": "uuid" + } +} +``` + +--- + +## 4. Текущее состояние ipwhitelist-app + +### 4.1. Как работает сейчас + +Приложение **НИ РАЗУ не вызывает IAM API**. Вся информация о пользователе и компаниях извлекается из JWT-токена в `src/auth.js`: + +```javascript +// src/auth.js — userFromPayload() +const rawClientId = payload.ClientID || payload.client_id || ''; +const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean); +// + поиск WZ* в claims массиве + +return { + clientId: activeClientId, // первый из allClientIds + allClientIds, // comma-separated из ClientID + activeClientId, // то же что и clientId + companyId: payload.company_id, + companyName: payload.company_name, + isAdmin: activeClientId === 'WZ01112', +}; +``` + +### 4.2. Проблемы + +1. **`allClientIds` из токена — ненадёжно.** JWT содержит только `ClientID` активной компании (одно значение). Comma-separated значения — хак/предположение, не гарантированное IAM. + +2. **Нет запроса к IAM API.** Приложение должно делать `GET /api/v1/auth/user` для получения актуального списка профилей. + +3. **`is_active_profile` не используется.** Приложение понятия не имеет, какой профиль активен с точки зрения IAM. Активная компания определяется как «первая в списке». + +4. **Переключение компаний — только локальное.** В `ui/routes/entries.js` параметр `?switchTo=` меняет `activeClientId` в сессии, но не вызывает `POST /switch-profile` на IAM. Если пользователь переключит компанию через ЛК, приложение об этом не узнает. + +5. **Проверка `isAdmin` — хардкод.** `activeClientId === 'WZ01112'` вместо использования `userInfo.isAdmin` из IAM. + +6. **Избыточный парсинг JWT.** Поля `company_id`, `company_name`, `ClientID` парсятся из токена, хотя правильный источник — ответ IAM API. + +### 4.3. Что должно быть + +| Данные | Сейчас (источник) | Должно быть (источник) | +|---|---|---| +| Список компаний | `ClientID` из JWT | `profiles[].client_id` из IAM | +| Активная компания | Первая из списка | `profiles[].is_active_profile === true` | +| WZ-код | `payload.ClientID` | `userInfo.clientID` | +| Название компании | `payload.company_name` | `userInfo.company` или `profiles` | +| UUID компании | `payload.company_id` | `userInfo.companyId` | +| isAdmin | `clientId === 'WZ01112'` | `userInfo.isAdmin` | +| Переключение | Сессия (`req.session.user.activeClientId`) | `POST /switch-profile` + сессия | + +--- + +## 5. План доработок + +### 5.1. `src/config.js` — добавить URL IAM API + +```javascript +const IAM_API_URL = process.env.IAM_API_URL || 'https://auth-api-test.ngcloud.ru'; +``` + +### 5.2. `src/auth.js` — добавить `fetchIamUser(token)` + +Новая функция, которая делает HTTP-запрос к `GET {IAM_API_URL}/api/v1/auth/user` и возвращает объект пользователя на основе ответа IAM: + +```javascript +async function fetchIamUser(token) { + // GET {IAM_API_URL}/api/v1/auth/user + // Authorization: Bearer + // Возвращает: + // { + // email: userInfo.email, + // clientId: userInfo.clientID, // активная компания + // allClientIds: profiles.map(p => p.client_id), + // activeProfileId: profiles.find(p => p.is_active_profile)?.id, + // companyId: userInfo.companyId, + // companyName: userInfo.company, + // isAdmin: userInfo.isAdmin, + // userId: userId, + // contactId: userInfo.contactId, + // fio: userInfo.fio, + // profiles: profiles, // полный список для UI + // } +} +``` + +### 5.3. `src/auth.js` — изменить `userFromPayload()` + +После получения токена (в OIDC-режиме) вызывать `fetchIamUser()` и использовать его результат вместо парсинга JWT. + +В mock-режиме — оставить текущую логику (IAM API недоступен локально). + +### 5.4. `src/auth.js` — добавить `switchProfile(token, profileId)` + +```javascript +async function switchProfile(token, profileId) { + // POST {IAM_API_URL}/api/v1/user/switch-profile + // Body: { profile_id: profileId } + // Возвращает новый active_profile +} +``` + +### 5.5. `ui/routes/entries.js` — изменить логику `?switchTo=` + +При переключении компании: +1. Вызвать `auth.switchProfile(token, profileId)` на IAM +2. Обновить `req.session.user` с новым `activeClientId` +3. Сохранить актуальный список `profiles` + +### 5.6. `ui/routes/entries.js` — передавать `profiles` в шаблон + +Для отображения списка доступных компаний в UI (выпадающий список с отметкой активной). + +--- + +## 6. Сетевая доступность — ddos-guard и TLS fingerprint + +### 6.1. Проблема + +Все стенды `*.ngcloud.ru` защищены ddos-guard. При попытке доступа через `curl` без флага `--http2` с сервера (IP `81.200.23.210`) — **403 Forbidden** на любых эндпоинтах. + +При этом **браузер с того же самого IP** проходит нормально. То есть ddos-guard блокирует не по IP, а по сочетанию протокола (HTTP/1.1 vs HTTP/2) и TLS-отпечатка клиента. + +### 6.2. Матрица доступности (проверено 2026-06-04) + +| Клиент | auth-api | deck-api-test | +|---|---|---| +| `curl` (HTTP/1.1, по умолчанию) | ❌ 403 | ❌ 403 | +| `curl --http2` | ✅ 200 | ❌ 403 | +| `curl --http2` + браузерные заголовки | ✅ 200 | ❌ 403 | +| `Node.js https` | ✅ 200 | ✅ 200 | +| `Python urllib` | ✅ 200 | Не проверялся | +| Браузер (Chrome/Firefox) | ✅ 200 | ✅ 200 | + +**Выводы:** +- `auth-api`: проблема curl решается флагом `--http2` +- `deck-api-test`: ddos-guard проверяет TLS fingerprint, curl не проходит даже с HTTP/2. Node.js — проходит +- **Приложение на Node.js работает везде** + +### 6.3. Как использовать curl (правильно!) + +```bash +# ❌ НЕ РАБОТАЕТ — HTTP/1.1 режется ddos-guard +curl -H "Authorization: Bearer $TOKEN" https://auth-api.ngcloud.ru/api/v1/auth/user + +# ✅ РАБОТАЕТ — HTTP/2 + браузерные заголовки +curl --http2 \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" \ + -H "Accept-Language: ru-RU,ru;q=0.9" \ + "https://auth-api.ngcloud.ru/api/v1/auth/user" + +# ✅ РАБОТАЕТ — Node.js (всегда) +node -e " +const https = require('https'); +https.get({ + hostname: 'auth-api.ngcloud.ru', + path: '/api/v1/auth/user', + headers: { Authorization: 'Bearer $TOKEN', Accept: 'application/json' } +}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d)); }); +" + +# ✅ РАБОТАЕТ — Python +python3 -c " +import urllib.request +req = urllib.request.Request('https://auth-api.ngcloud.ru/api/v1/auth/user', + headers={'Authorization': 'Bearer $TOKEN', 'Accept': 'application/json'}) +print(urllib.request.urlopen(req, timeout=10).read().decode()) +" +``` + +### 6.4. Значение для приложения + +Приложение **ipwhitelist-app** написано на Node.js — оно **может** ходить в IAM API с этого сервера без проблем. `curl` без `--http2` врал про отсутствие доступа. + +--- + +## 7. Проверка токенов (подтверждено) + +### 7.1. Типы токенов + +| Тип | `token_type` | TTL | Где взять | +|---|---|---|---| +| **access** | `access` | 12 часов | Ответ IAM после SSO через Keycloak (`POST /token`) | +| **refresh** | `refresh` | 7 дней | Там же, для обновления access | +| **tech** | `tech` | 6 месяцев | ЛК → вкладка «Токены» → «Выпустить тех-токен» | + +### 7.2. Все токены подходят для `GET /api/v1/auth/user` + +Не важно, access или tech — любой валидный IAM-токен (`iss: "auth-api"`) принимается. Источник токена (ЛК, SSO через Keycloak, другое приложение) значения не имеет. + +### 7.3. Проверка access_token из ЛК (2026-06-04) + +```bash +# Токен, полученный через F12 → Network → /token response → access_token +node -e "const https=require('https'); +https.get({hostname:'auth-api.ngcloud.ru', path:'/api/v1/auth/user', + headers:{Authorization:'Bearer $ACCESS_TOKEN', Accept:'application/json'}}, + res=>{let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d))});" +# → HTTP 200 + JSON с profiles[] и userInfo +``` + +### 7.4. Тестовый пользователь + +| Поле | Test | Prod | +|---|---|---| +| `email` | `tazetdinovn@gmail.com` | `tazetdinovn@gmail.com` | +| `clientID` | `WZ03709` | `WZ03709` | +| `company_name` | `naeel_test` | `naeel_test` | +| `company_numeric_id` | 2645 | 2645 | +| `profile.id` | 2833 | 4357 | +| `elmaUserId` | 17193 | 39715 | +| `isAdmin` | false | false | +| `is_active_profile` | true | true | +| Количество профилей | 1 | 1 | + +**Внимание:** у этого пользователя один профиль. Реальная картина с несколькими компаниями будет содержать несколько объектов в `profiles[]`. + +### 7.5. Поток аутентификации (подтверждён) + +``` +Пользователь → Keycloak (SSO) → IAM (обмен code) → access_token + │ + ┌───────────────────────────────┘ + ▼ + GET /api/v1/auth/user (Bearer access_token) + │ + ▼ + { profiles[], userInfo } + │ + ▼ + Строим session.user +``` + +**Никакого дополнительного обмена токенами не требуется.** `access_token` от IAM сразу годится для вызова `GET /api/v1/auth/user`. + +### 6.3. Токен + +Использованный для тестирования токен: +- **Тип:** tech-токен (долгоживущий, 6 месяцев) +- **Issuer:** `auth-api` +- **Пользователь:** `tazetdinovn@gmail.com` +- **ClientID:** `WZ03709` +- **Компания:** `naeel_test` (numeric_id: 2645) +- **Выпущен:** 31 мая 2026 +- **Истекает:** 30 ноября 2026 +- **Роль:** Пользователь (не админ) +- **Стенд:** test (`auth-api-test.ngcloud.ru`) + +--- + +## 7. Файлы, которые нужно изменить + +| Файл | Что изменить | +|---|---| +| `.env.example` | Добавить `IAM_API_URL` | +| `src/config.js` | Добавить константу `IAM_API_URL` из env | +| `src/auth.js` | Добавить `fetchIamUser()`, `switchProfile()`, изменить `userFromPayload()` | +| `ui/routes/auth.js` | После логина вызывать `fetchIamUser` для обогащения user | +| `src/routes/oidc.js` | После callback вызывать `fetchIamUser` | +| `ui/routes/entries.js` | `?switchTo=` → вызывать IAM `switchProfile`, передавать `profiles` в шаблон | +| `views/index.ejs` | Отображать список компаний с активной | +| `src/api/routes/entries.js` | `resolveCompany()` — использовать `profiles` вместо `allClientIds` из токена | + +--- + +## 8. Диаграмма целевого потока + +```mermaid +sequenceDiagram + participant B as Браузер + participant A as ipwhitelist-app + participant KC as Keycloak + participant IAM as IAM API + + B->>A: Заход на / + A->>KC: Редирект на /auth + B->>KC: Вход (SSO если есть сессия) + KC->>B: Редирект с code + B->>A: /callback?code=xxx + A->>KC: POST /token (обмен code) + KC-->>A: access_token + A->>IAM: GET /auth/user (Bearer access_token) + IAM-->>A: { profiles[], userInfo } + A->>A: Строим user из IAM ответа + A->>B: Сессия создана, добро пожаловать + + Note over B,A: Пользователь переключает компанию + + B->>A: /?switchTo=WZ12345 + A->>IAM: POST /user/switch-profile { profile_id: 3120 } + IAM-->>A: { success: true, active_profile } + A->>A: Обновляем сессию + A->>B: Страница с новой активной компанией +``` diff --git a/docs/legacy-ТЗ-плюс.md b/docs/legacy-ТЗ-плюс.md new file mode 100644 index 0000000..8928c99 --- /dev/null +++ b/docs/legacy-ТЗ-плюс.md @@ -0,0 +1,161 @@ +# ТЗ-плюс — уточнения и дополнения от заказчика + +> Основа: `docs/ТЗ.md` +> Файл для фиксации уточнений, дополнений и решений по мере обсуждения с заказчиком/DevOps. +> Каждая запись = дата + источник + формулировка + статус. + +--- + +## 1. Аутентификация и Keycloak + +### 1.1. client_id приложения в Keycloak +- **Дата**: 2026-06-02 +- **Источник**: HAR-файл +- **Факт**: `client_id` присутствует в URL auth-запроса +- **Статус**: 🔴 требует подтверждения — какой client_id будет для ipwhitelist? + +### 1.2. ClientID в JWT — УТОЧНЕНО +- **ТЗ**: claim `ClientID` — идентификатор компании +- **Факт (HAR)**: `ClientID: "WZ01325"` +- **Факт (другой источник)**: `ClientID` может отсутствовать +- **Уточнение заказчика (01.06.2026)**: в claims приходит `clientid`. Формат может быть разный. + - Одно значение: `WZ04228`, `1700`, `asokolov-test` + - Несколько через запятую: `WZ11125, WZ03816`, `WZ52235, WZ62587, WZ02315` + - **Логика**: первое «слово» до запятой определяет общий ЛК. + Пользователи с одинаковым первым словом → общий whitelist. +- **Что делать в коде**: брать первый `clientid` до запятой как активную компанию. + Остальные (если есть) — дополнительные компании пользователя для переключателя. +- **Статус**: 🟡 уточнено, требует реализации в коде + +### 1.3. Несколько компаний на пользователя — УТОЧНЕНО +- **ТЗ, п.3.1**: «поддерживается сценарий, когда пользователь принадлежит нескольким компаниям» +- **Уточнение заказчика (01.06.2026)**: поле `clientid` в claims содержит список через запятую. + Примеры: `WZ11125, WZ03816`, `WZ52235, WZ62587, WZ02315`. +- **Логика ЛК**: первое значение до запятой — активная компания. + Пользователи с одинаковым первым `clientid` видят общий whitelist. + Переключатель между компаниями — все значения из списка. +- **Открытый вопрос**: какое именно поле в Keycloak за это отвечает? Заказчик уточнит. +- **Статус**: 🟡 уточнено, требует реализации в коде + +### 1.4. Admin-роль в Keycloak +- **ТЗ**: admin = `ClientID = WZ01112` + отдельный чек-бокс +- **Вопрос**: чек-бокс — это claim? Какой? `is_admin`, `realm_access.roles`, `groups`? +- **Статус**: 🔴 + +### 1.5. JWKS URL +- **Вариант A**: `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs` +- **Вариант B**: `https://auth-api...` (через API Gateway) +- **Статус**: 🔴 + +### 1.6. Redirect URI +- **Предложение**: `https://<домен>/callback` +- **Статус**: 🔴 требует подтверждения DevOps + +--- + +## 2. Инфраструктура и деплой + +### 2.1. Платформа +- **Статус**: 🔴 уточнить — Node.js на k8s? Какой кластер? + +### 2.2. Деплой +- **Статус**: 🔴 уточнить — CI/CD? Как поставлять `jsonEnv`? + +### 2.3. Сетевое ограничение для /export +- **Вопрос**: какие IP/подсети будут потребителями внешней выдачи? +- **Статус**: 🔴 + +--- + +## 3. Функциональные уточнения + +### 3.1. Мульти-компания в UI +- **ТЗ, п.3.1**: «переключатель активной компании» +- **Вопрос**: дизайн переключателя? Dropdown в шапке? Отдельная страница? +- **Статус**: 🔴 + +### 3.2. Soft-delete и фильтр «показать удалённые» +- **ТЗ, п.4.1**: «для администратора предусмотрен фильтр» +- **Код**: `includeDeleted` всегда `false` +- **Статус**: 🟡 не реализовано в коде + +### 3.3. Индивидуальные лимиты компаний +- **ТЗ, п.4.5**: админ может задать индивидуальный лимит +- **Код**: ? +- **Статус**: 🔴 проверить реализацию + +### 3.4. Уведомление о нормализации +- **ТЗ, п.5**: «пользователь должен быть уведомлен, что ввел адрес из хостовой части» +- **Статус**: 🔴 проверить реализацию в UI + +--- + +## 4. Тестовые данные + +### 4.1. Тестовые пользователи + +| Email | ClientID | Компания | Роль | +|---|---|---|---| +| `client@example.com` | `WZ01325` | Тест | Клиент | +| `admin@nubes.ru` | `WZ01112` | Нубес | Админ | +| `tazetdinovn@gmail.com` | ? | ? | ? (из token.txt) | + +### 4.2. HAR-сессия +- Файл: `Files/nubes_login.har` +- Пользователь: `tazet@narod.ru` +- Дата: 2026-05-30 +- Окружение: deck-test + +--- + +## 6. Уточнения от заказчика (01.06.2026) + +### 6.1. Формат clientid в токене +- Поле: `clientid` в claims JWT +- Формат: строка, значения через запятую если несколько +- Примеры: + - `"WZ04228"` — одна компания + - `"1700"` — числовой ID + - `"asokolov-test"` — текстовый ID + - `"WZ11125, WZ03816"` — две компании + - `"WZ52235, WZ62587, WZ02315"` — три компании +- **Правило**: первое «слово» до запятой = активная компания (определяет ЛК) +- **Общий доступ**: пользователи с одинаковым первым словом имеют общий whitelist + +### 6.2. Экспорт — подтверждение формата +- GET-endpoint отдаёт txt файл +- Одна строка = один объект (адрес/подсеть) +- Без разделителей, без заголовков + +### 6.3. Текущее демо — одобрено +- `https://white.nodejsk8s.dev.nubes.ru/login` — ✓ +- `https://white.nodejsk8s.dev.nubes.ru/exp` — ✓ (вывод без токена для тестирования) + +### 6.4. Что ещё уточняется +- Какое именно поле Keycloak маппится в `clientid` claim? (Заказчик уточнит) +- Как определяется admin-роль? (отдельный чек-бокс в Keycloak?) +- `client_id` и `client_secret` для регистрации приложения + +### 6.5. Права внутри компании — уточнено (02.06.2026) +- **Вопрос**: может ли любой юзер компании редактировать whitelist всей компании? +- **Ответ заказчика**: «Да, любой юзер, который может войти может редактировать» +- **Вывод**: внутри компании роли не разграничиваются. Любой сотрудник компании имеет полный доступ к whitelist своей компании (создание, редактирование, удаление). Аудит фиксирует кто именно сделал изменение. + +--- + +## 5. Решения и договорённости + +> Сюда записывать принятые решения с датой и контекстом. + +(пока пусто) + +--- + +## Легенда статусов + +| Статус | Значение | +|---|---| +| 🔴 | Требует уточнения | +| 🟡 | Уточнено, не реализовано | +| 🟢 | Реализовано / подтверждено | +| ⚫ | Отменено / не актуально | diff --git a/docs/legacy-ТЗ-реализация.md b/docs/legacy-ТЗ-реализация.md new file mode 100644 index 0000000..b5c6491 --- /dev/null +++ b/docs/legacy-ТЗ-реализация.md @@ -0,0 +1,242 @@ +Техническое задание +Микросервис управления доверенными адресами клиентов +Self-service портал для указания клиентами доверенных IPv4-адресов и подсетей, +исключаемых из блокировки на стороне облачного провайдера во время DDoS-атак + +# 1. Назначение и цели + +## 1.1. Назначение + +Микросервис предоставляет клиентам облачного провайдера web-интерфейс для самостоятельного управления списком доверенных IPv4-адресов и подсетей. Записи из этого списка исключаются из автоматической блокировки сетевого взаимодействия системами фильтрации и митигации провайдера, что снижает количество ложноположительных срабатываний для легитимного трафика клиента. + +> **Реализация:** `server.js` — точка входа, подключает все роуты. `ui/index.js` — UI-слой. + +## 1.2. Цели + +- Дать клиентам возможность самостоятельно поддерживать актуальный список доверенных IPv4-адресов, которые будут исключаться из фильтрации во время DDoS-атак. + + > **Реализация:** `ui/routes/entries.js` — UI CRUD. `src/api/routes/entries.js` — REST API. + +- Предоставить сетевым инженерам единую точку просмотра и управления списками доверенных клиентских белых IPv4-адресов. + + > **Реализация:** `ui/routes/admin.js` — /admin и /audit UI. `src/api/routes/admin.js` — API /companies, /audit. + +- Обеспечить машиночитаемую выдачу агрегированного (суммаризированного) списка для систем фильтрации трафика. + + > **Реализация:** `src/routes/export.js` — GET /export. `src/validators.js` строки 125–181 — функция `aggregateCIDRs`. + +# 2. Объем работ + +- Web-страница / закладка в личном кабинете для управления whitelist-записями. + + > **Реализация:** `views/index.ejs` — главная страница. `ui/routes/entries.js` — роуты GET /, POST /add, POST /edit/:id, POST /delete/:id. + +- Авторизация через существующий экземпляр Keycloak (OIDC). + + > **Реализация:** `src/auth.js` строки 220–260 — верификация JWT RS256 токена Keycloak. Строка 245: чтение claim `ClientID`. Строка 251: определение роли `isAdmin`. + +- Валидация формы на стороне клиента и сервера. + + > **Реализация:** `src/validators.js` строки 26–79 — серверная валидация `validate()`. `views/index.ejs` — атрибут `pattern` в поле ввода (клиентская). + +- Внешний endpoint выдачи агрегированного списка. Выдача txt-файлом с переносом строки. Одна строка – один объект. + + > **Реализация:** `src/routes/export.js` строки 27–64 — GET /export, Content-Type: text/plain, aggregateCIDRs → join('\n'). + +- Хранение записей, журнал аудита. + + > **Реализация:** `sql/schema.sql` — таблицы `whitelist_entries`, `audit_log`. `src/queries.js` строки 211–230 — функция `logAudit`. + +- Административное управление лимитами по компаниям. + + > **Реализация:** `src/api/routes/admin.js` строки 27–35 — PATCH /companies/:id/limit. `src/queries.js` — функция `setLimit`. `views/admin.ejs` — UI формы лимитов. + +# 3. Роли и права доступа + +Роли определяются на основании claims в OIDC-токене Keycloak. Соответствие claim → роль настраивается на этапе развёртывания. + +> **Реализация:** `src/auth.js` строка 31: `ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'`. Строка 251: `isAdmin: clientId === ADMIN_CLIENT_ID`. + +| Роль | Идентификация | Видимость записей | Права на изменение | +| --- | --- | --- | --- | +| Клиент (client) | clientId | Только записи компаний, к которым принадлежит пользователь. | Создание, редактирование и удаление записей своих компаний (в пределах лимита). | +| Администратор (admin) | clientId = WZ01112 (Нубес) и отдельный чек-бокс | Записи всех компаний. | Создание, редактирование, удаление всех записей. Изменение лимита для отдельных компаний. | + +> **Реализация ролей в API:** `src/api/routes/entries.js` — функция `resolveCompany()`: если `req.user.isAdmin && req.query.company` → берёт чужую компанию, иначе `getOrCreateCompany(clientId)`. `src/auth.js` строки 260–265 — middleware `requireAdmin`. + +## 3.1. Принадлежность к компании + +Принадлежность пользователя к компании определяется из claim токена. Поддерживается сценарий, когда пользователь принадлежит нескольким компаниям: в этом случае в интерфейсе предусматривается переключатель активной компании, а все операции выполняются в контексте выбранной компании. + +> **Реализация:** `src/auth.js` строка 245: `clientId = payload.ClientID`. `src/queries.js` строки 6–15: `getOrCreateCompany(clientId, companyName)` — атомарный upsert. +> +> ⚠️ **Сценарий нескольких компаний не реализован** — в реальном токене Nubes `ClientID` = одна строка, массива нет. Ждём уточнения от devops. + +Ожидаемые claims (имена согласуются с командой Keycloak): +- `ClientID` — идентификатор компании → `src/auth.js` строка 245 +- `email` — идентификация пользователя для аудита → `src/auth.js` строка 248 + +# 4. Функциональные требования + +## 4.1. Просмотр списка записей + +- Клиент видит таблицу записей активной компании; Администратор – записи всех компаний с фильтром по компании. + + > **Реализация:** `ui/routes/entries.js` — GET /: для admin грузит `getAllCompanies()` + фильтр по `?company=`. `views/index.ejs` — dropdown компаний для admin, таблица записей. + +- Для каждой записи отображаются: значение (адрес/подсеть), комментарий (если есть), автор(email), дата создания, дата последнего изменения. + + > **Реализация:** `views/index.ejs` строки 280–295 — колонки таблицы: `value_cidr`, `comment`, `created_by`, `created_at`, `updated_at`. Даты в timezone `Europe/Moscow`. + +- Soft-deleted записи по умолчанию скрыты; для администратора предусмотрен фильтр для их отображения. + + > **Реализация:** `src/queries.js` строки 25–31 — `listEntries(companyId, includeDeleted)`. SQL: `AND deleted_at IS NULL` когда `includeDeleted=false`. + > + > ⚠️ **Фильтр "показать удалённые" в UI не реализован** — `includeDeleted` всегда `false`. + +- Отображается текущее использование лимита: «использовано X из N». + + > **Реализация:** `src/api/routes/entries.js` строка 62: `res.json({ entries, limit, used: entries.length })`. `views/index.ejs` — блок статистики `<%= used %> / <%= limit %>`. + +## 4.2. Создание записи + +- Форма содержит поля: значение (IPv4-адрес или подсеть CIDR) и необязательный комментарий (до 255 символов). + + > **Реализация:** `views/index.ejs` — форма POST /add с полями `value` и `comment`. Атрибут `maxlength="18"` на поле адреса. + +- Значение проходит валидацию (см. раздел 5) на клиенте и обязательно повторно на сервере. + + > **Реализация:** `src/validators.js` строки 26–79 — `validate(input)`. Вызывается в `src/queries.js` строка 33: `const { cidr, wasNormalized } = validate(rawValue)`. + +- Перед сохранением проверяется: соблюдение лимита компании, отсутствие пересечений и дубликатов внутри компании, отсутствие принадлежности к запрещённым диапазонам. + + > **Реализация:** `src/queries.js` строки 32–70 — `createEntry()`: блокировка строки компании (FOR UPDATE), проверка лимита (строки 43–46), проверка дубликатов и пересечений (строки 49–57), проверка запрещённых диапазонов в `validate()` (строки 73–75 validators.js). + +- При успешном сохранении создаётся запись аудита. + + > **Реализация:** `src/queries.js` строка 66: `logAudit(userEmail, companyId, 'CREATE', null, cidr, ...)`. + +## 4.3. Редактирование записи + +- Редактирование значения и комментария доступно компании в рамках своих прав. + + > **Реализация:** `ui/routes/entries.js` — POST /edit/:id. `src/api/routes/entries.js` — PATCH /entries/:id. Modal в `views/index.ejs` — кнопка `.btn-edit`, event delegation в `