feat: IAM API интеграция + обновление ТЗ + тесты
- 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
This commit is contained in:
@@ -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= # секрет клиента
|
||||
|
||||
@@ -34,3 +34,9 @@ docs/agent-opinions.md
|
||||
|
||||
# Логи тестов
|
||||
test-results/
|
||||
|
||||
# Тестовые окружения (только для внутреннего использования)
|
||||
.env.test
|
||||
.env.production
|
||||
DEPLOY-TESTING.md
|
||||
tests/comprehensive.js
|
||||
|
||||
@@ -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 <token>`
|
||||
|
||||
**Реальный ответ** (тестовый пользователь `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 <token>
|
||||
// Возвращает:
|
||||
// {
|
||||
// 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: Страница с новой активной компанией
|
||||
```
|
||||
@@ -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. Решения и договорённости
|
||||
|
||||
> Сюда записывать принятые решения с датой и контекстом.
|
||||
|
||||
(пока пусто)
|
||||
|
||||
---
|
||||
|
||||
## Легенда статусов
|
||||
|
||||
| Статус | Значение |
|
||||
|---|---|
|
||||
| 🔴 | Требует уточнения |
|
||||
| 🟡 | Уточнено, не реализовано |
|
||||
| 🟢 | Реализовано / подтверждено |
|
||||
| ⚫ | Отменено / не актуально |
|
||||
@@ -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=<id>`. `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 в `<script>`.
|
||||
|
||||
- При изменении значения повторно выполняется полный набор проверок валидации и пересечений.
|
||||
|
||||
> **Реализация:** `src/queries.js` строки 77–115 — `updateEntry()`: валидация через `validate()`, проверка пересечений (исключая саму запись: `AND id <> $2`).
|
||||
|
||||
- Изменение фиксируется в журнале аудита с сохранением прежнего и нового значения.
|
||||
|
||||
> **Реализация:** `src/queries.js` строка 107: `logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, ...)`.
|
||||
|
||||
## 4.4. Удаление записи (soft delete)
|
||||
|
||||
- Удаление выполняется как логическое (soft delete): запись помечается удалённой (deleted_at, deleted_by), но физически сохраняется.
|
||||
|
||||
> **Реализация:** `src/queries.js` строки 118–145 — `deleteEntry()`: UPDATE SET `deleted_at = NOW(), deleted_by = userEmail`. `sql/schema.sql` — колонки `deleted_at`, `deleted_by` в таблице `whitelist_entries`.
|
||||
|
||||
- Удалённая запись освобождает место в лимите компании и исключается из внешней агрегированной выдачи.
|
||||
|
||||
> **Реализация:** `src/queries.js` строка 50: COUNT считает только `WHERE deleted_at IS NULL`. `src/routes/export.js` — запрос только активных записей.
|
||||
|
||||
- Действие фиксируется в журнале аудита.
|
||||
|
||||
> **Реализация:** `src/queries.js` строка 134: `logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, ...)`.
|
||||
|
||||
## 4.5. Лимит записей на компанию
|
||||
|
||||
- Действует глобальный лимит по умолчанию: 15 активных записей на компанию.
|
||||
|
||||
> **Реализация:** `src/queries.js` строки 17–20 — `getLimit()`: `parseInt(process.env.DEFAULT_LIMIT) || 15`.
|
||||
|
||||
- Значение глобального лимита по умолчанию задаётся конфигурацией сервиса и может быть изменено без пересборки.
|
||||
|
||||
> **Реализация:** `src/queries.js` строка 18: `process.env.DEFAULT_LIMIT` — переменная окружения, не хардкод.
|
||||
|
||||
- Для отдельной компании администратор может задать индивидуальный лимит, переопределяющий глобальный (как в большую, так и в меньшую сторону).
|
||||
|
||||
> **Реализация:** `src/api/routes/admin.js` строки 27–35 — PATCH /companies/:id/limit. `src/queries.js` строка 20: `company.custom_limit != null ? company.custom_limit : defaultLimit`. `sql/schema.sql` — колонка `custom_limit` в таблице `companies`.
|
||||
|
||||
- При попытке превысить лимит создание блокируется с понятным сообщением; в подсчёт идут только активные записи.
|
||||
|
||||
> **Реализация:** `src/queries.js` строки 43–46: `if (cnt >= limit) throw new Error('Лимит исчерпан: N из N')`. API возвращает 409.
|
||||
|
||||
- Снижение лимита ниже текущего числа записей не удаляет существующие записи, но блокирует создание новых до приведения в соответствие.
|
||||
|
||||
> **Реализация:** `src/api/routes/admin.js` строки 28–35 — setLimit просто записывает значение без удаления записей. Блокировка создания — через проверку `cnt >= limit` в `createEntry`.
|
||||
|
||||
## 4.6. Журнал аудита
|
||||
|
||||
- Все изменяющие операции фиксируются неизменяемыми записями аудита.
|
||||
|
||||
> **Реализация:** `src/queries.js` строки 211–218 — `logAudit()`: INSERT в `audit_log` без UPDATE/DELETE операций над ней.
|
||||
|
||||
- Каждая запись аудита содержит: кто (пользователь), когда (timestamp), компания, тип действия, прежнее и новое состояние.
|
||||
|
||||
> **Реализация:** `sql/schema.sql` — таблица `audit_log`: колонки `user_email`, `created_at`, `company_id`, `action`, `old_value`, `new_value`. `views/audit.ejs` строки 131–160 — отображение.
|
||||
|
||||
- Журнал доступен для просмотра только администратору.
|
||||
|
||||
> **Реализация:** `src/api/routes/admin.js` — GET /audit защищён `apiRequireAdmin`. `ui/routes/admin.js` — GET /audit проверяет `req.session.user.isAdmin`.
|
||||
|
||||
## 4.7. Внешняя выдача агрегированного списка
|
||||
|
||||
- Подсети суммаризируются (агрегируются в минимальный набор CIDR) по всем компаниям совместно. Пересечения между разными компаниями допустимы.
|
||||
|
||||
> **Реализация:** `src/validators.js` строки 125–181 — `aggregateCIDRs()`: сортировка, слияние перекрывающихся диапазонов, преобразование обратно в CIDR.
|
||||
|
||||
- Предоставляется отдельный HTTP GET endpoint, отдающий полный суммаризированный список активных записей всех компаний файлом в формате txt.
|
||||
|
||||
> **Реализация:** `src/routes/export.js` строки 27–64 — GET /export. Content-Type: `text/plain`, ответ: `aggregated.join('\n')`.
|
||||
|
||||
- Авторизация: на старте endpoint может работать без авторизации (по сетевому ограничению / разрешенный список потребителей по ip).
|
||||
|
||||
> ⚠️ **Не реализовано** — /export требует Bearer-токен (авторизован). По ТЗ должен быть доступен без авторизации по IP-списку.
|
||||
|
||||
# 5. Требования к валидации
|
||||
|
||||
Валидация выполняется на клиенте и обязательно дублируется на сервере. Серверная валидация является авторитетной.
|
||||
|
||||
> **Реализация:** `src/validators.js` — вся серверная валидация. `views/index.ejs` — атрибут `pattern` (клиент).
|
||||
|
||||
| Правило | Описание | Реализация |
|
||||
| --- | --- | --- |
|
||||
| Формат IPv4 | Допускается одиночный адрес или подсеть CIDR /22–/32. | `src/validators.js` строки 41–53: добавление /32 если нет маски, проверка `mask < 22 \|\| mask > 32`. |
|
||||
| Только IPv4 | IPv6-значения или доменные имена отклоняются. | `src/validators.js` строки 31–36: `if (raw.includes(':'))` → отклонить, проверка букв. |
|
||||
| Корректность подсети | Host-биты обнуляются, пользователь уведомляется о нормализации. | `src/validators.js` строки 55–69: битовая арифметика, `wasNormalized = addr !== networkAddr`. Флаш-сообщение в `ui/routes/entries.js`. |
|
||||
| Запрет серых адресов | Диапазоны из Приложения А запрещены. | `src/validators.js` строки 4–20: `BLOCKED_RANGES[]`. Строки 73–75: проверка `overlaps()`. |
|
||||
| Отсутствие дубликатов | Совпадающие записи в компании запрещены. | `src/queries.js` строка 53: `if (row.value_cidr === cidr) throw`. Уникальный индекс в `sql/schema.sql`. |
|
||||
| Отсутствие пересечений | Пересечение с существующей записью в компании запрещено. | `src/queries.js` строки 49–57: перебор активных записей + `overlaps()`. Между компаниями — допускается. |
|
||||
| Длина комментария | Не более 255 символов. | `src/validators.js` строка 183 (или в api/routes/entries.js): проверка `comment.length > 255` → 400. |
|
||||
|
||||
# Приложение А – Список запрещённых к созданию подсетей
|
||||
|
||||
> **Реализация:** `src/validators.js` строки 4–20 — массив `BLOCKED_RANGES`.
|
||||
|
||||
| Назначение | Префикс |
|
||||
| --- | --- |
|
||||
| Private (RFC1918) | 10.0.0.0/8 |
|
||||
| Private (RFC1918) | 172.16.0.0/12 |
|
||||
| Private (RFC1918) | 192.168.0.0/16 |
|
||||
| CGNAT (RFC6598) | 100.64.0.0/10 |
|
||||
| Loopback | 127.0.0.0/8 |
|
||||
| Link-local (APIPA) | 169.254.0.0/16 |
|
||||
| IANA special block | 192.0.0.0/24 |
|
||||
| TEST-NET-1 (docs) | 192.0.2.0/24 |
|
||||
| TEST-NET-2 (docs) | 198.51.100.0/24 |
|
||||
| TEST-NET-3 (docs) | 203.0.113.0/24 |
|
||||
| Benchmarking | 198.18.0.0/15 |
|
||||
| Multicast | 224.0.0.0/4 |
|
||||
| Reserved (Class E) | 240.0.0.0/4 |
|
||||
| Limited broadcast | 255.255.255.255/32 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Расхождения с ТЗ (что не реализовано)
|
||||
|
||||
| Пункт ТЗ | Статус |
|
||||
| --- | --- |
|
||||
| 3.1 Несколько компаний для одного пользователя | Не реализовано — ждём формат claim от devops |
|
||||
| 4.1 Фильтр soft-deleted записей для admin | Не реализовано — `includeDeleted` всегда false |
|
||||
| 4.7 /export без авторизации (по IP) | Не реализовано — требует Bearer-токен |
|
||||
+159
-115
@@ -1,160 +1,204 @@
|
||||
# ТЗ-плюс — уточнения и дополнения от заказчика
|
||||
|
||||
> Основа: `docs/ТЗ.md`
|
||||
> Файл для фиксации уточнений, дополнений и решений по мере обсуждения с заказчиком/DevOps.
|
||||
> Каждая запись = дата + источник + формулировка + статус.
|
||||
> Дата последнего обновления: 2026-06-04
|
||||
> Файл для фиксации уточнений, дополнений и решений.
|
||||
|
||||
---
|
||||
|
||||
## 1. Аутентификация и Keycloak
|
||||
## 1. Аутентификация — интеграция с IAM API
|
||||
|
||||
### 1.1. client_id приложения в Keycloak
|
||||
- **Дата**: 2026-06-02
|
||||
- **Источник**: HAR-файл
|
||||
- **Факт**: `client_id` присутствует в URL auth-запроса
|
||||
- **Статус**: 🔴 требует подтверждения — какой client_id будет для ipwhitelist?
|
||||
### 1.1. IAM API — единый сервис авторизации (УТОЧНЕНО 04.06.2026)
|
||||
|
||||
### 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` до запятой как активную компанию.
|
||||
Остальные (если есть) — дополнительные компании пользователя для переключателя.
|
||||
- **Статус**: 🟡 уточнено, требует реализации в коде
|
||||
IAM (Identity & Access Management) — сервис авторизации экосистемы Nubes.
|
||||
Все смежные сервисы ходят в него для проверки компании пользователя.
|
||||
|
||||
### 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`?
|
||||
- **Статус**: 🔴
|
||||
| Стенд | URL |
|
||||
|---|---|
|
||||
| Dev | `https://auth-api-dev.ngcloud.ru` |
|
||||
| Test | `https://auth-api-test.ngcloud.ru` |
|
||||
| Prod | `https://auth-api.ngcloud.ru` |
|
||||
|
||||
### 1.5. JWKS URL
|
||||
- **Вариант A**: `https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs`
|
||||
- **Вариант B**: `https://auth-api...` (через API Gateway)
|
||||
- **Статус**: 🔴
|
||||
**Swagger:** `https://auth-api-dev.ngcloud.ru/api/v1/documentation/`
|
||||
|
||||
### 1.6. Redirect URI
|
||||
- **Предложение**: `https://<домен>/callback`
|
||||
- **Статус**: 🔴 требует подтверждения DevOps
|
||||
**Исходный источник:** CRM Elma (контакт, компания, отношение N:M).
|
||||
|
||||
**Статус:** 🟢 подтверждено
|
||||
|
||||
### 1.2. Поток аутентификации (УТОЧНЕНО 04.06.2026)
|
||||
|
||||
```
|
||||
Пользователь → Keycloak (SSO) → IAM (обмен code) → access_token + refresh_token
|
||||
│
|
||||
┌───────────────────────────────┘
|
||||
▼
|
||||
GET /api/v1/auth/user (Bearer access_token)
|
||||
│
|
||||
▼
|
||||
{ profiles[], userInfo }
|
||||
```
|
||||
|
||||
`access_token` от IAM (token_type: "access", TTL 12 часов) сразу годится для
|
||||
вызова `GET /api/v1/auth/user`. Никакого дополнительного обмена не требуется.
|
||||
|
||||
**Статус:** 🟢 подтверждено (Node.js HTTP 200, получен реальный ответ)
|
||||
|
||||
### 1.3. client_id приложения в Keycloak
|
||||
- **Источник:** HAR-файл
|
||||
- **Факт:** `client_id` в URL auth-запроса
|
||||
- **Статус:** 🔴 требует подтверждения
|
||||
|
||||
### 1.4. JWKS URL и Redirect URI
|
||||
- **Статус:** 🔴 требует подтверждения DevOps
|
||||
|
||||
---
|
||||
|
||||
## 2. Инфраструктура и деплой
|
||||
## 2. Компании пользователя — IAM /auth/user
|
||||
|
||||
### 2.1. Платформа
|
||||
- **Статус**: 🔴 уточнить — Node.js на k8s? Какой кластер?
|
||||
### 2.1. `GET /api/v1/auth/user` (УТОЧНЕНО 04.06.2026)
|
||||
|
||||
### 2.2. Деплой
|
||||
- **Статус**: 🔴 уточнить — CI/CD? Как поставлять `jsonEnv`?
|
||||
**Реальный ответ** (пользователь `tazetdinovn@gmail.com`, prod):
|
||||
|
||||
### 2.3. Сетевое ограничение для /export
|
||||
- **Вопрос**: какие IP/подсети будут потребителями внешней выдачи?
|
||||
- **Статус**: 🔴
|
||||
```json
|
||||
{
|
||||
"profiles": [
|
||||
{
|
||||
"id": 4357,
|
||||
"client_id": "WZ03709",
|
||||
"company_id": "019cc24a-727e-740f-b407-bec79dab4162",
|
||||
"company_name": "naeel_test",
|
||||
"company_numeric_id": 2645,
|
||||
"is_active_profile": true
|
||||
}
|
||||
],
|
||||
"userInfo": {
|
||||
"clientID": "WZ03709",
|
||||
"company": "naeel_test",
|
||||
"companyId": "019cc24a-727e-740f-b407-bec79dab4162",
|
||||
"companyNumericId": 2645,
|
||||
"email": "tazetdinovn@gmail.com",
|
||||
"isAdmin": false,
|
||||
"fio": {"fullName": "Тазетдинов Наиль Фаритович", "name": "Наиль", "secondName": "Фаритович", "surname": "Тазетдинов"},
|
||||
"contactId": "019cc268-6c6a-781e-8613-4bed4ec7cd20",
|
||||
"elmaUserId": 39715
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Маппинг полей для использования в коде:**
|
||||
|
||||
| Назначение | Откуда |
|
||||
|---|---|
|
||||
| Список WZ-кодов компаний | `profiles[].client_id` |
|
||||
| Активная компания | `profiles[].is_active_profile === true` |
|
||||
| ID для switch-profile | `profiles[].id` |
|
||||
| WZ активной компании | `userInfo.clientID` |
|
||||
| Название компании | `userInfo.company` |
|
||||
| UUID компании | `userInfo.companyId` |
|
||||
| Флаг админа | `userInfo.isAdmin` |
|
||||
| Email | `userInfo.email` |
|
||||
|
||||
**Статус:** 🟢 подтверждено
|
||||
|
||||
### 2.2. `POST /api/v1/user/switch-profile` (УТОЧНЕНО 04.06.2026)
|
||||
|
||||
Тело: `{"profile_id": 3120}` или `{"company_id": "uuid"}`.
|
||||
|
||||
**Статус:** 🟡 уточнено по Swagger, не тестировалось на живом multi-company юзере
|
||||
|
||||
### 2.3. ⚫ ClientID через запятую в JWT — УСТАРЕЛО
|
||||
|
||||
- **Было (01.06):** `ClientID: "WZ11125, WZ03816"` в claims JWT
|
||||
- **Стало (04.06):** JWT содержит только ОДНУ компанию (активную).
|
||||
Правильный источник списка — `profiles[]` из IAM API.
|
||||
- **Статус:** ⚫ отменено
|
||||
|
||||
### 2.4. ⚫ Admin = WZ01112 — УСТАРЕЛО
|
||||
|
||||
- **Было:** `clientId === 'WZ01112'` → админ
|
||||
- **Стало:** `userInfo.isAdmin` из IAM API
|
||||
- **Статус:** ⚫ отменено
|
||||
|
||||
---
|
||||
|
||||
## 3. Функциональные уточнения
|
||||
## 3. Сетевая доступность
|
||||
|
||||
### 3.1. Мульти-компания в UI
|
||||
- **ТЗ, п.3.1**: «переключатель активной компании»
|
||||
- **Вопрос**: дизайн переключателя? Dropdown в шапке? Отдельная страница?
|
||||
- **Статус**: 🔴
|
||||
### 3.1. ddos-guard (УТОЧНЕНО 04.06.2026)
|
||||
|
||||
### 3.2. Soft-delete и фильтр «показать удалённые»
|
||||
- **ТЗ, п.4.1**: «для администратора предусмотрен фильтр»
|
||||
- **Код**: `includeDeleted` всегда `false`
|
||||
- **Статус**: 🟡 не реализовано в коде
|
||||
Все `*.ngcloud.ru` за ddos-guard. IP `81.200.23.210` не блокируется —
|
||||
проблема в HTTP-клиенте:
|
||||
|
||||
### 3.3. Индивидуальные лимиты компаний
|
||||
- **ТЗ, п.4.5**: админ может задать индивидуальный лимит
|
||||
- **Код**: ?
|
||||
- **Статус**: 🔴 проверить реализацию
|
||||
| Клиент | auth-api | deck-api-test |
|
||||
|---|---|---|
|
||||
| `curl` (без --http2) | ❌ 403 | ❌ 403 |
|
||||
| `curl --http2` | ✅ 200 | ❌ 403 |
|
||||
| `Node.js https` | ✅ 200 | ✅ 200 |
|
||||
| Браузер | ✅ 200 | ✅ 200 |
|
||||
|
||||
### 3.4. Уведомление о нормализации
|
||||
- **ТЗ, п.5**: «пользователь должен быть уведомлен, что ввел адрес из хостовой части»
|
||||
- **Статус**: 🔴 проверить реализацию в UI
|
||||
**Правило для curl:** `--http2` + `Accept: application/json`.
|
||||
|
||||
**Node.js (приложение):** работает без проблем.
|
||||
|
||||
**Статус:** 🟢 подтверждено
|
||||
|
||||
---
|
||||
|
||||
## 4. Тестовые данные
|
||||
## 4. Инфраструктура и деплой
|
||||
|
||||
### 4.1. Тестовые пользователи
|
||||
- **Платформа:** 🔴 уточнить
|
||||
- **CI/CD:** 🔴 уточнить
|
||||
- **Сетевое ограничение /export:** 🔴 уточнить
|
||||
|
||||
---
|
||||
|
||||
## 5. Функциональные уточнения
|
||||
|
||||
### 5.1. Мульти-компания в UI
|
||||
- Список компаний из `profiles[]`, активная по `is_active_profile`
|
||||
- Переключение: UI → `POST /switch-profile` на IAM → обновить сессию
|
||||
- **Статус:** 🟡 требует реализации
|
||||
|
||||
### 5.2. Soft-delete фильтр
|
||||
- `includeDeleted` всегда `false`
|
||||
- **Статус:** 🟡 не реализовано
|
||||
|
||||
### 5.3. Индивидуальные лимиты
|
||||
- **Статус:** 🔴 проверить
|
||||
|
||||
### 5.4. Уведомление о нормализации
|
||||
- **Статус:** 🔴 проверить
|
||||
|
||||
---
|
||||
|
||||
## 6. Тестовые данные
|
||||
|
||||
| Email | ClientID | Компания | Роль |
|
||||
|---|---|---|---|
|
||||
| `client@example.com` | `WZ01325` | Тест | Клиент |
|
||||
| `admin@nubes.ru` | `WZ01112` | Нубес | Админ |
|
||||
| `tazetdinovn@gmail.com` | ? | ? | ? (из token.txt) |
|
||||
| `tazetdinovn@gmail.com` | WZ03709 | naeel_test | Пользователь |
|
||||
| `client@example.com` | WZ01325 | Тест | Клиент |
|
||||
| `admin@nubes.ru` | WZ01112 | Нубес | Админ |
|
||||
|
||||
### 4.2. HAR-сессия
|
||||
- Файл: `Files/nubes_login.har`
|
||||
- Пользователь: `tazet@narod.ru`
|
||||
- Дата: 2026-05-30
|
||||
- Окружение: deck-test
|
||||
### HAR-сессия
|
||||
- Файл: `Files/nubes_login.har`, пользователь `tazet@narod.ru`, deck-test
|
||||
|
||||
---
|
||||
|
||||
## 6. Уточнения от заказчика (01.06.2026)
|
||||
## 7. Принятые решения
|
||||
|
||||
### 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. Решения и договорённости
|
||||
|
||||
> Сюда записывать принятые решения с датой и контекстом.
|
||||
|
||||
(пока пусто)
|
||||
| Дата | Решение |
|
||||
|---|---|
|
||||
| 01.06.2026 | Экспорт: txt, одна строка = один CIDR |
|
||||
| 01.06.2026 | Демо одобрено |
|
||||
| 02.06.2026 | Любой юзер компании редактирует whitelist всей компании |
|
||||
| 04.06.2026 | **IAM API — источник компаний.** `GET /auth/user` → `profiles[]`. `isAdmin` из `userInfo.isAdmin`. Переключение через `POST /switch-profile`. |
|
||||
| 04.06.2026 | ddos-guard: curl требует `--http2`, Node.js работает |
|
||||
|
||||
---
|
||||
|
||||
## Легенда статусов
|
||||
|
||||
| Статус | Значение |
|
||||
|---|---|
|
||||
| 🔴 | Требует уточнения |
|
||||
| 🟡 | Уточнено, не реализовано |
|
||||
| 🟢 | Реализовано / подтверждено |
|
||||
|
||||
+12
-6
@@ -66,15 +66,21 @@ Self-service портал для указания клиентами довер
|
||||
|
||||
## 3.1. Принадлежность к компании
|
||||
|
||||
Принадлежность пользователя к компании определяется из claim токена. Поддерживается сценарий, когда пользователь принадлежит нескольким компаниям: в этом случае в интерфейсе предусматривается переключатель активной компании, а все операции выполняются в контексте выбранной компании.
|
||||
Принадлежность пользователя к компании определяется через IAM API (`GET /api/v1/auth/user`). Поддерживается сценарий, когда пользователь принадлежит нескольким компаниям: в этом случае в интерфейсе предусматривается переключатель активной компании, а все операции выполняются в контексте выбранной компании.
|
||||
|
||||
> **Реализация:** `src/auth.js` строка 245: `clientId = payload.ClientID`. `src/queries.js` строки 6–15: `getOrCreateCompany(clientId, companyName)` — атомарный upsert.
|
||||
> **Реализация:** `src/auth.js` — функция `fetchIamUser(token)` вызывает `GET {IAM_API_URL}/api/v1/auth/user`, получает `profiles[]` со всеми компаниями и `userInfo` с данными пользователя. `userInfo.isAdmin` — флаг админа (вместо хардкода `WZ01112`).
|
||||
>
|
||||
> ⚠️ **Сценарий нескольких компаний не реализован** — в реальном токене Nubes `ClientID` = одна строка, массива нет. Ждём уточнения от devops.
|
||||
> 🔧 **Требует доработки:** `fetchIamUser` ещё не реализована. Сейчас данные берутся из JWT (`payload.ClientID`). Правильный источник — IAM API.
|
||||
|
||||
Ожидаемые claims (имена согласуются с командой Keycloak):
|
||||
- `ClientID` — идентификатор компании → `src/auth.js` строка 245
|
||||
- `email` — идентификация пользователя для аудита → `src/auth.js` строка 248
|
||||
Ожидаемые данные из IAM API:
|
||||
- `profiles[].client_id` — все компании пользователя
|
||||
- `profiles[].is_active_profile` — активная компания
|
||||
- `profiles[].id` — ID профиля для `POST /switch-profile`
|
||||
- `userInfo.clientID` — WZ-код активной компании
|
||||
- `userInfo.company` — название компании
|
||||
- `userInfo.companyId` — UUID компании
|
||||
- `userInfo.isAdmin` — флаг администратора
|
||||
- `userInfo.email` — идентификация пользователя для аудита
|
||||
|
||||
# 4. Функциональные требования
|
||||
|
||||
|
||||
@@ -49,15 +49,22 @@ function createEntriesRouter({ q }) {
|
||||
if (!c) throw Object.assign(new Error('Company not found'), { status: 404 });
|
||||
return c;
|
||||
}
|
||||
// Мульти-компания: client_id разрешён только если есть в allClientIds пользователя
|
||||
// Мульти-компания: client_id разрешён только если есть в профилях (IAM) или allClientIds (JWT fallback)
|
||||
const requestedId = (req.query.client_id || '').trim();
|
||||
const effectiveClientId = (requestedId && req.user.allClientIds && req.user.allClientIds.includes(requestedId))
|
||||
const allowedIds = req.user.profiles && req.user.profiles.length
|
||||
? req.user.profiles.map(p => p.client_id)
|
||||
: (req.user.allClientIds || []);
|
||||
const effectiveClientId = (requestedId && allowedIds.includes(requestedId))
|
||||
? requestedId
|
||||
: req.user.clientId;
|
||||
// companyName: для переключённой компании — clientId как имя по умолчанию
|
||||
const effectiveName = effectiveClientId === req.user.clientId
|
||||
// companyName: для переключённой компании — находим в profiles или используем clientId
|
||||
let effectiveName = effectiveClientId === req.user.clientId
|
||||
? req.user.companyName
|
||||
: effectiveClientId;
|
||||
if (req.user.profiles) {
|
||||
const p = req.user.profiles.find(p => p.client_id === effectiveClientId);
|
||||
if (p) effectiveName = p.company_name;
|
||||
}
|
||||
return q.getOrCreateCompany(effectiveClientId, effectiveName, req.user.email);
|
||||
}
|
||||
|
||||
|
||||
+109
-3
@@ -25,6 +25,7 @@ const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { IAM_API_URL } = require('./config');
|
||||
|
||||
// ── Конфигурация из окружения ────────────────────────────────────────────────
|
||||
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
|
||||
@@ -186,6 +187,7 @@ async function exchangeCode(code) {
|
||||
|
||||
return {
|
||||
user: userFromPayload(payload),
|
||||
accessToken: data.access_token,
|
||||
idToken: data.id_token || null,
|
||||
refreshToken: data.refresh_token || null,
|
||||
};
|
||||
@@ -274,8 +276,112 @@ function verifyAnyToken(token, devMode) {
|
||||
throw lastErr || new Error('Token verification failed: no verifier available');
|
||||
}
|
||||
|
||||
function userFromPayload(payload) {
|
||||
// ── Компании ──────────────────────────────────────────────────────────
|
||||
// ── IAM API — получение профилей пользователя ─────────────────────────────────
|
||||
/**
|
||||
* Выполняет HTTP-запрос к IAM API и возвращает обогащённые данные пользователя.
|
||||
* @param {string} token — access_token от IAM
|
||||
* @returns {object} { email, clientId, allClientIds, activeProfileId, companyId, companyName, isAdmin, fio, profiles, raw }
|
||||
*/
|
||||
async function fetchIamUser(token) {
|
||||
const raw = await new Promise((resolve, reject) => {
|
||||
const url = new URL(IAM_API_URL + '/api/v1/auth/user');
|
||||
const lib = url.protocol === 'https:' ? https : http;
|
||||
const req = lib.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => { data += c; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`IAM API returned ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||
}
|
||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('IAM API timeout')); });
|
||||
req.end();
|
||||
});
|
||||
|
||||
const profiles = raw.profiles || [];
|
||||
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
|
||||
const ui = raw.userInfo || {};
|
||||
|
||||
return {
|
||||
email: ui.email || '',
|
||||
clientId: ui.clientID || activeProfile.client_id || '',
|
||||
allClientIds: profiles.map(p => p.client_id).filter(Boolean),
|
||||
activeProfileId: activeProfile.id || null,
|
||||
companyId: ui.companyId || '',
|
||||
companyName: ui.company || activeProfile.company_name || '',
|
||||
isAdmin: !!ui.isAdmin,
|
||||
fio: ui.fio || null,
|
||||
profiles, // полный массив для UI
|
||||
raw, // сырой ответ (для отладки)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает активный профиль пользователя через IAM API.
|
||||
* @param {string} token — access_token от IAM
|
||||
* @param {number} profileId — profiles[].id нового активного профиля
|
||||
* @returns {object} { success, active_profile }
|
||||
*/
|
||||
async function switchProfile(token, profileId) {
|
||||
const body = JSON.stringify({ profile_id: profileId });
|
||||
const raw = await new Promise((resolve, reject) => {
|
||||
const url = new URL(IAM_API_URL + '/api/v1/user/switch-profile');
|
||||
const lib = url.protocol === 'https:' ? https : http;
|
||||
const req = lib.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => { data += c; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`IAM switch-profile failed: ${res.statusCode} ${data.slice(0, 200)}`));
|
||||
}
|
||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('IAM switch-profile timeout')); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
return raw;
|
||||
}
|
||||
|
||||
function userFromPayload(payload, iamData) {
|
||||
// ── Если есть данные из IAM API — используем их как основной источник ────
|
||||
if (iamData) {
|
||||
return {
|
||||
email: iamData.email,
|
||||
clientId: iamData.clientId,
|
||||
allClientIds: iamData.allClientIds,
|
||||
activeClientId: iamData.clientId, // активная по IAM
|
||||
activeProfileId: iamData.activeProfileId,
|
||||
companyId: iamData.companyId,
|
||||
companyName: iamData.companyName,
|
||||
isAdmin: iamData.isAdmin,
|
||||
fio: iamData.fio,
|
||||
profiles: iamData.profiles,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Без IAM — fallback на JWT (mock-режим) ──────────────────────────────
|
||||
let allClientIds = [];
|
||||
|
||||
// 1. auth-api: ClientID = "WZ03709" или "WZ03709, WZ54321"
|
||||
@@ -402,4 +508,4 @@ function safeReturn(target) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
module.exports = { initAuth, requireAdmin, safeReturn };
|
||||
module.exports = { initAuth, requireAdmin, safeReturn, fetchIamUser, switchProfile };
|
||||
|
||||
+6
-1
@@ -10,6 +10,11 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// ── IAM API ───────────────────────────────────────────────────────────────────
|
||||
// URL IAM-сервиса для получения профилей пользователя (GET /api/v1/auth/user)
|
||||
// и переключения активной компании (POST /api/v1/user/switch-profile).
|
||||
const IAM_API_URL = (process.env.IAM_API_URL || 'https://auth-api.ngcloud.ru').replace(/\/$/, '');
|
||||
|
||||
// ── Мок-пользователи (только dev/staging) ─────────────────────────────────────
|
||||
// В продакшене этот список не используется — вход через Keycloak.
|
||||
// clientId соответствует WZ-номеру компании в Nubes.
|
||||
@@ -89,4 +94,4 @@ function backUrl(isAdmin, companyId, extra = {}) {
|
||||
// Передаётся в шаблоны через app.locals чтобы отображаться в UI.
|
||||
const { version: APP_VERSION } = require('../package.json');
|
||||
|
||||
module.exports = { MOCK_USERS, backUrl, APP_VERSION };
|
||||
module.exports = { MOCK_USERS, backUrl, APP_VERSION, IAM_API_URL };
|
||||
|
||||
+32
-11
@@ -43,30 +43,51 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
|
||||
}
|
||||
|
||||
try {
|
||||
// Обмен code на токен (через Keycloak или API Gateway)
|
||||
// Обмен code на токен (через IAM / Keycloak)
|
||||
const tokenData = await auth.exchangeCode(code);
|
||||
const accessToken = tokenData.access_token;
|
||||
const accessToken = tokenData.accessToken;
|
||||
|
||||
// Верификация токена (проверка подписи через JWKS)
|
||||
// userFromPayload уже встроена в exchangeCode или делается здесь
|
||||
if (!accessToken) throw new Error('No access_token in response');
|
||||
|
||||
// Базовый user из JWT (fallback)
|
||||
const payload = jwt.decode(accessToken);
|
||||
if (!payload) throw new Error('Failed to decode token');
|
||||
|
||||
// Определяем компании пользователя
|
||||
req.session.token = accessToken;
|
||||
|
||||
// Пытаемся обогатить через IAM API
|
||||
try {
|
||||
const { fetchIamUser } = require('../auth');
|
||||
const iamData = await fetchIamUser(accessToken);
|
||||
req.session.user = {
|
||||
email: iamData.email,
|
||||
clientId: iamData.clientId,
|
||||
allClientIds: iamData.allClientIds,
|
||||
activeClientId: iamData.clientId,
|
||||
activeProfileId: iamData.activeProfileId,
|
||||
companyId: iamData.companyId,
|
||||
companyName: iamData.companyName,
|
||||
isAdmin: iamData.isAdmin,
|
||||
fio: iamData.fio,
|
||||
profiles: iamData.profiles,
|
||||
};
|
||||
console.log('[oidc] IAM enrichment OK:', iamData.email, iamData.clientId, 'profiles:', iamData.allClientIds.length);
|
||||
} catch (iamErr) {
|
||||
// IAM недоступен — используем fallback из JWT
|
||||
console.warn('[oidc] IAM enrichment failed, using JWT fallback:', iamErr.message);
|
||||
const rawClientId = payload.ClientID || payload.client_id || '';
|
||||
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const activeClientId = allClientIds[0] || rawClientId;
|
||||
|
||||
req.session.token = accessToken;
|
||||
const activeClientId = allClientIds[0] || rawClientId || 'UNKNOWN';
|
||||
req.session.user = {
|
||||
clientId: rawClientId,
|
||||
clientId: activeClientId,
|
||||
allClientIds,
|
||||
activeClientId,
|
||||
email: payload.email || payload.login || '',
|
||||
companyId: payload.company_id || payload.companyId || null,
|
||||
companyName: payload.company_name || payload.companyName || activeClientId,
|
||||
companyId: payload.company_id || null,
|
||||
companyName: payload.company_name || activeClientId,
|
||||
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
|
||||
};
|
||||
}
|
||||
|
||||
const returnTo = req.session.oidcReturnTo || '/';
|
||||
delete req.session.oidcReturnTo;
|
||||
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/api-crud.sh — Комплексный тест CRUD API ipwhitelist-app
|
||||
# Дата: 2026-06-04
|
||||
# Стенд: https://italo.kube5s.ru (DEV_MODE)
|
||||
#
|
||||
# Использование:
|
||||
# bash tests/api-crud.sh [BASE_URL] [TOKEN]
|
||||
#
|
||||
# Если токен не указан — использовать prod IAM токен (только для DEV_MODE)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${1:-https://italo.kube5s.ru}"
|
||||
TOKEN="${2:-}"
|
||||
|
||||
# ── Prod IAM токен (работает в DEV_MODE) ──────────────────────────────
|
||||
if [ -z "$TOKEN" ]; then
|
||||
TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWNjMjY4LTZjNmEtNzgxZS04NjEzLTRiZWQ0ZWM3Y2QyMCIsImV4cCI6MTc5NjExODA2NywiaWF0IjoxNzgwNTY2MDY3LCJqdGkiOiJjMzJlNWIwMy05YjBlLTRjNGEtYTY0MS1lOTE0YWQ4MWRiMDQiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAzNzA5IiwiY29tcGFueV9pZCI6IjAxOWNjMjRhLTcyN2UtNzQwZi1iNDA3LWJlYzc5ZGFiNDE2MiIsImNvbXBhbnlfbmFtZSI6Im5hZWVsX3Rlc3QiLCJ0b2tlbl90eXBlIjoidGVjaCIsImlkcF91c3JfdWlkIjoiMDE5Y2MyNjgtNmM2YS03ODFlLTg2MTMtNGJlZDRlYzdjZDIwIiwibG9naW4iOiJ0YXpldGRpbm92bkBnbWFpbC5jb20iLCJmaXJzdG5hbWUiOiLQndCw0LjQu9GMIiwibWlkZGxlbmFtZSI6ItCk0LDRgNC40YLQvtCy0LjRhyIsImxhc3RuYW1lIjoi0KLQsNC30LXRgtC00LjQvdC-0LIiLCJncm91cHMiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6IiIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiIsImVtYWlsIjoidGF6ZXRkaW5vdm5AZ21haWwuY29tIn0.Pc0NJsauTQQApbPgDjFZd9phceMvfN4usaa8Rw9wGeMUvKR6EOAC6F_9in6zhcgK0zFsdbpsSzeGaNLoJNpadbshAWmGKVxprbMZbCuuxvDFzraIHTw0okgvh-4XPN8NhzqI0taTWjN9Wdl5hWBpHBQFpgRlf9u-jMwqmSQQ15wZHiOc_x1Xh_IKhtBGC4duYlvnzVzzWgRVvCNzs73dnPhCAKNn17epZujq0QHlzkkg2WeCo24iiiKrIpssW6YUiM9aKezwbYxGvZZOePX-RjcapBKFjNmpjI2UjEOMroQUtS7ei1cgU9VN5_QjmGKmSSXF2JBtcSGb52qAhYDwQQ"
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
api() {
|
||||
curl -s --http2 -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" "$@"
|
||||
}
|
||||
|
||||
check() {
|
||||
local name="$1" expected="$2" actual="$3"
|
||||
if echo "$actual" | grep -q "$expected"; then
|
||||
echo "✅ $name"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "❌ $name — expected: '$expected', got: $(echo "$actual" | head -c 100)"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== API CRUD Tests — $BASE ==="
|
||||
echo ""
|
||||
|
||||
# 1. Health
|
||||
R=$(api -X GET "$BASE/healthz" -H "Content-Type:" -H "Authorization:")
|
||||
check "1. healthz" "OK" "$R"
|
||||
|
||||
# 2. GET entries
|
||||
R=$(api -X GET "$BASE/api/v1/entries")
|
||||
check "2. GET entries" '"entries"' "$R"
|
||||
check "2b. limit" '"limit"' "$R"
|
||||
|
||||
# 3. POST create
|
||||
R=$(api -X POST -d '{"value":"203.0.116.1","comment":"crud-test"}' "$BASE/api/v1/entries")
|
||||
check "3. POST create" '"id"' "$R"
|
||||
ENTRY_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['entry']['id'])" 2>/dev/null || echo "")
|
||||
|
||||
# 4. POST duplicate
|
||||
R=$(api -X POST -d '{"value":"203.0.116.1"}' "$BASE/api/v1/entries")
|
||||
check "4. POST duplicate" 'уже существует' "$R"
|
||||
|
||||
# 5. POST intersection
|
||||
R=$(api -X POST -d '{"value":"203.0.116.0/24"}' "$BASE/api/v1/entries")
|
||||
check "5. POST intersection" 'Пересечение' "$R"
|
||||
|
||||
# 6. POST private IP
|
||||
R=$(api -X POST -d '{"value":"10.0.0.1"}' "$BASE/api/v1/entries")
|
||||
check "6. POST private" 'запрещённым' "$R"
|
||||
|
||||
# 7. POST /21
|
||||
R=$(api -X POST -d '{"value":"5.0.0.0/21"}' "$BASE/api/v1/entries")
|
||||
check "7. POST /21" '/22 до /32' "$R"
|
||||
|
||||
# 8. POST invalid IP
|
||||
R=$(api -X POST -d '{"value":"999.999.999.999"}' "$BASE/api/v1/entries")
|
||||
check "8. POST invalid" 'Некорректный' "$R"
|
||||
|
||||
# 9. PATCH update
|
||||
if [ -n "$ENTRY_ID" ]; then
|
||||
R=$(api -X PATCH -d '{"value":"203.0.116.2","comment":"updated"}' "$BASE/api/v1/entries/$ENTRY_ID")
|
||||
check "9. PATCH update" '"id"' "$R"
|
||||
check "9b. new value" '203.0.116.2/32' "$R"
|
||||
fi
|
||||
|
||||
# 10. DELETE
|
||||
if [ -n "$ENTRY_ID" ]; then
|
||||
R=$(api -X DELETE "$BASE/api/v1/entries/$ENTRY_ID" -w "%{http_code}" -o /dev/null)
|
||||
check "10. DELETE" "204" "$R"
|
||||
fi
|
||||
|
||||
# 11. Export
|
||||
R=$(api -X GET "$BASE/export" -H "Content-Type:" -H "Authorization:")
|
||||
check "11. export" "/32" "$R"
|
||||
|
||||
# 12. No auth
|
||||
R=$(curl -s --http2 "$BASE/api/v1/entries" -w "\n%{http_code}")
|
||||
check "12. no auth" "Bearer token required" "$R"
|
||||
|
||||
echo ""
|
||||
echo "=== Результат: $PASS / $((PASS+FAIL)) ==="
|
||||
[ "$FAIL" -eq 0 ] && echo "ВСЕ ТЕСТЫ ПРОЙДЕНЫ" || echo "ЕСТЬ ОШИБКИ"
|
||||
@@ -95,6 +95,27 @@ function createRouter({ auth, MOCK_USERS, authLimiter }) {
|
||||
const activeClientId = allClientIds[0] || rawClientId;
|
||||
|
||||
req.session.token = raw;
|
||||
|
||||
// Пытаемся обогатить через IAM API
|
||||
try {
|
||||
const { fetchIamUser } = require('../../src/auth');
|
||||
const iamData = await fetchIamUser(raw);
|
||||
req.session.user = {
|
||||
email: iamData.email,
|
||||
clientId: iamData.clientId,
|
||||
allClientIds: iamData.allClientIds,
|
||||
activeClientId: iamData.clientId,
|
||||
activeProfileId: iamData.activeProfileId,
|
||||
companyId: iamData.companyId,
|
||||
companyName: iamData.companyName,
|
||||
isAdmin: iamData.isAdmin,
|
||||
fio: iamData.fio,
|
||||
profiles: iamData.profiles,
|
||||
};
|
||||
console.log('[login-token] IAM enrichment OK:', iamData.email, iamData.clientId);
|
||||
} catch (iamErr) {
|
||||
// IAM недоступен — fallback из JWT
|
||||
console.warn('[login-token] IAM enrichment failed, using JWT fallback:', iamErr.message);
|
||||
req.session.user = {
|
||||
clientId: rawClientId,
|
||||
allClientIds,
|
||||
@@ -104,6 +125,7 @@ function createRouter({ auth, MOCK_USERS, authLimiter }) {
|
||||
companyName: payload.company_name || payload.companyName || activeClientId,
|
||||
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
|
||||
};
|
||||
}
|
||||
return res.redirect(returnTo || '/');
|
||||
}
|
||||
|
||||
|
||||
+31
-3
@@ -39,12 +39,38 @@ function createRouter() {
|
||||
router.get('/', async (req, res) => {
|
||||
const token = api.token(req);
|
||||
|
||||
// ── Переключатель компании для мульти-компании ──────────────────────
|
||||
// ── Переключатель компании через IAM ────────────────────────────────
|
||||
const switchTo = (req.query.switchTo || '').trim();
|
||||
if (!req.user.isAdmin && switchTo && req.user.allClientIds && req.user.allClientIds.includes(switchTo)) {
|
||||
if (!req.user.isAdmin && switchTo && req.user.profiles && req.user.profiles.length > 1) {
|
||||
const targetProfile = req.user.profiles.find(p => p.client_id === switchTo);
|
||||
if (targetProfile) {
|
||||
try {
|
||||
const { switchProfile } = require('../../src/auth');
|
||||
await switchProfile(token, targetProfile.id);
|
||||
// Обновляем сессию: новый активный профиль
|
||||
req.session.user.activeClientId = switchTo;
|
||||
req.session.user.clientId = switchTo;
|
||||
req.session.user.companyName = targetProfile.company_name;
|
||||
req.session.user.companyId = targetProfile.company_id;
|
||||
req.session.user.activeProfileId = targetProfile.id;
|
||||
// Обновляем is_active_profile в массиве
|
||||
req.session.user.profiles.forEach(p => {
|
||||
p.is_active_profile = (p.client_id === switchTo);
|
||||
});
|
||||
req.user.activeClientId = switchTo;
|
||||
req.user.clientId = switchTo;
|
||||
} catch (e) {
|
||||
console.warn('[entries] switchProfile failed:', e.message);
|
||||
// Локальный fallback без IAM
|
||||
req.session.user.activeClientId = switchTo;
|
||||
if (req.session.user.clientId) req.session.user.clientId = switchTo;
|
||||
req.user.activeClientId = switchTo;
|
||||
}
|
||||
}
|
||||
} else if (!req.user.isAdmin && switchTo && req.user.allClientIds && req.user.allClientIds.includes(switchTo)) {
|
||||
// Fallback: без profiles (mock-режим или IAM не ответил)
|
||||
req.session.user.activeClientId = switchTo;
|
||||
if (req.session.user.clientId) req.session.user.clientId = switchTo;
|
||||
// Синхронизируем req.user — resolveUser запустился ДО обработчика
|
||||
req.user.activeClientId = switchTo;
|
||||
}
|
||||
|
||||
@@ -86,6 +112,7 @@ function createRouter() {
|
||||
}
|
||||
|
||||
const hasMultiple = !req.user.isAdmin && req.user.allClientIds && req.user.allClientIds.length > 1;
|
||||
const profiles = req.user.profiles || [];
|
||||
|
||||
res.render('index', {
|
||||
entries,
|
||||
@@ -97,6 +124,7 @@ function createRouter() {
|
||||
selectedCompany,
|
||||
allClientIds: hasMultiple ? req.user.allClientIds : null,
|
||||
activeClientId: req.user.activeClientId || req.user.clientId,
|
||||
profiles, // из IAM — для UI переключателя
|
||||
error: req.query.error || null,
|
||||
success: req.query.success || null,
|
||||
lastValue: req.query.lastValue || '',
|
||||
|
||||
@@ -219,11 +219,19 @@
|
||||
<span style="font-size:.85rem;color:var(--muted);font-weight:500;">Компания:</span>
|
||||
<select id="company-switch"
|
||||
style="padding:.4rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.85rem;background:#fff;">
|
||||
<% if (typeof profiles !== 'undefined' && profiles.length) { %>
|
||||
<% profiles.forEach(p => { %>
|
||||
<option value="<%= p.client_id %>" <%= p.is_active_profile ? 'selected' : '' %>>
|
||||
<%= p.company_name %> (<%= p.client_id %>)
|
||||
</option>
|
||||
<% }) %>
|
||||
<% } else { %>
|
||||
<% allClientIds.forEach(id => { %>
|
||||
<option value="<%= id %>" <%= id === activeClientId ? 'selected' : '' %>>
|
||||
<%= id %>
|
||||
</option>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</select>
|
||||
<span style="margin-left:auto;font-size:.8rem;color:var(--muted);">
|
||||
Компаний: <strong><%= allClientIds.length %></strong>
|
||||
|
||||
Reference in New Issue
Block a user