Files
ipwhitelist-app/docs/sonnet-impersonation-analysis.md
T
naeel 1350f0dbba security: fix OIDC state check bypass + open redirect in login-token
- oidc.js: remove || true — state check now works in all environments
- auth.js: wrap returnTo in safeReturn (was unprotected on line 166)
2026-06-11 20:02:49 +04:00

334 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Анализ архитектуры имперсонации — ipwhitelist-app
> Только анализ. Код не правился.
---
## 1. Диагностика: что именно сломано и почему
### 1.1 Фундаментальная причина рассинхрона
Приложение состоит из **двух независимых слоёв**, каждый со своей точкой формирования `req.user`:
| Слой | Middleware | Источник `req.user` |
|------|-----------|---------------------|
| **UI** | `resolveUser` в `ui/index.js` | `jwt.decode(session.token)` + `req.session.user` |
| **API** | `bearerMiddleware` в `src/auth.js` | `verifyAnyToken(Bearer)``userFromPayload(payload)` |
Когда UI выполняет операцию (POST /add, DELETE /edit/:id), он делает HTTP-запрос к `/api/v1/entries` через `ui/api-client.js`. В этом запросе передаётся **только Bearer-токен** (`req.session.token`). Сессионный контекст (в том числе флаги имперсонации) **не передаётся**.
API-слой получает оригинальный KC-токен → вызывает `userFromPayload(payload)` → получает оригинальный email из JWT-клеймов. Никакой имперсонации.
### 1.2 Что произходит шаг за шагом (тестовая имперсонация)
```
[Браузер] → GET /
→ UI resolveUser: читает session.user.email + env IMPERSONATION_*
→ req.user.email = "test@test.ru" (целевой)
→ req.user.isImpersonated = true
→ рендер index.ejs: жёлтый баннер ✓
[Браузер] → POST /add (form submit)
→ UI route /add → api.post('/api/v1/entries?...', token, body)
→ api-client.js: HTTP POST к localhost:3000/api/v1/entries
headers: Authorization: Bearer <original-KC-token>
(без сессионных данных!)
[API bearerMiddleware]
→ verifyAnyToken(bearer) → userFromPayload(payload)
→ req.user.email = "ntazetdinov@nubes.ru" (оригинальный, из JWT!)
→ req.user.originalUserEmail = undefined
[API POST /api/v1/entries]
→ createEntry(company.id, value, comment,
"ntazetdinov@nubes.ru", // ← created_by: НЕПРАВИЛЬНО
undefined // ← impersonated_by: NULL, НЕПРАВИЛЬНО
)
```
**Итог:** баннер в UI отображает имперсонацию корректно, но в БД всё пишется от реального пользователя. Аудит не содержит `impersonated_by`.
### 1.3 Состояние реальной имперсонации IAM
При реальной IAM-имперсонации ситуация **немного лучше**, но тоже неполная:
- `/callback` вызывает `fetchIamUser(accessToken)` → сохраняет `isImpersonated`, `originalUserEmail` в `session.user`
- `resolveUser` в `ui/index.js` читает `req.session.user.isImpersonated``req.user.isImpersonated = true`
- НО: `req.user.email` берётся из JWT-клейма, а KC-токен содержит email **того, кто логинился** (администратора, начавшего имперсонацию)
- Поэтому неясно: какой email в KC-токене при реальной IAM-имперсонации — администратора или жертвы?
Скорее всего KC токен всегда содержит email авторизованного пользователя (администратора), а `iamData.email` из `fetchIamUser` содержит email **целевого** пользователя (кого имперсонируют). Это нужно проверить при тесте.
### 1.4 Проблема 4 файлов
```
src/routes/oidc.js → запись session.user при логине (единственный раз)
ui/index.js → resolveUser: TEST IMP + IAM IMP для UI-слоя
src/auth.js → userFromPayload: только JWT, нет session, нет IMP
src/api/routes/entries.js → req.user от bearerMiddleware (оригинальный)
```
Логика имперсонации размазана по трём местам и не доходит до четвёртого.
---
## 2. Ответы на вопросы
### Вопрос 1: Где должно быть единое место вычисления параметров имперсонации?
**`src/auth.js`, функция `resolveImpersonation(sessionUser, jwtPayload)`.**
Но с оговоркой: функция должна получать `sessionUser` (из `req.session.user`) как параметр, потому что `userFromPayload` в API-слое сессии не видит. Единое место **вычисления** — хорошо, но единое место **применения** требует двух вызовов с разными источниками данных.
Предлагаемая сигнатура:
```js
/**
* Вычисляет итоговые параметры имперсонации.
* @param {object} baseUser — user из JWT (email, clientId, allClientIds)
* @param {object} sessionUser — req.session.user (может быть null в API-слое)
* @returns {object} { email, activeClientId, isImpersonated, originalUserEmail }
*/
function resolveImpersonation(baseUser, sessionUser) {
// 1. Реальная IAM-имперсонация (приоритет)
if (sessionUser && sessionUser.isImpersonated) {
return {
email: sessionUser.email || baseUser.email,
activeClientId: sessionUser.activeClientId || baseUser.activeClientId,
isImpersonated: true,
originalUserEmail: sessionUser.originalUserEmail || '',
};
}
// 2. Тестовая имперсонация (env-переменные)
const impOriginal = process.env.IMPERSONATION_ORIGINAL || '';
const impTarget = process.env.IMPERSONATION_TARGET || '';
const impCompany = process.env.IMPERSONATION_COMPANY || '';
const email = (sessionUser && sessionUser.email) || baseUser.email || '';
if (impOriginal && email === impOriginal) {
return {
email: impTarget || email,
activeClientId: impCompany || baseUser.activeClientId,
isImpersonated: true,
originalUserEmail: impOriginal,
};
}
// 3. Нет имперсонации
return {
email: email || baseUser.email,
activeClientId: (sessionUser && sessionUser.activeClientId) || baseUser.activeClientId,
isImpersonated: false,
originalUserEmail: '',
};
}
```
### Вопрос 2: Как передавать `originalUserEmail` в `logAudit`?
Текущий механизм (`req.user.originalUserEmail` → параметр в CRUD-функциях) **правильный по структуре**, но ломается из-за того, что API-слой не наполняет `req.user.originalUserEmail`.
Проблема в API-слое: `bearerMiddleware``userFromPayload` → нет сессии → нет `originalUserEmail`.
**Два варианта передачи:**
**Вариант A (рекомендуется): кастомные заголовки из api-client**
В `ui/api-client.js` при вызове API добавлять заголовки, если у `req.user` есть флаг имперсонации:
```js
// В apiRequest добавить параметр extraHeaders
if (impersonation.isImpersonated) {
opts.headers['X-Impersonated-Email'] = impersonation.email; // целевой
opts.headers['X-Impersonated-By-Email'] = impersonation.originalUserEmail; // оригинальный
opts.headers['X-Impersonated-Company'] = impersonation.activeClientId;
}
```
В `bearerMiddleware` читать эти заголовки и переопределять `req.user`:
```js
const xEmail = req.headers['x-impersonated-email'];
if (xEmail && /* базовая валидация */) {
req.user.email = xEmail;
req.user.originalUserEmail = req.headers['x-impersonated-by-email'] || '';
req.user.isImpersonated = true;
req.user.activeClientId = req.headers['x-impersonated-company'] || req.user.activeClientId;
}
```
⚠️ **Важно:** эти заголовки должны приниматься ТОЛЬКО от localhost (127.0.0.1), иначе внешний клиент сможет имперсонировать кого угодно.
**Вариант B (проще, но менее чисто): читать env в bearerMiddleware**
В `bearerMiddleware` после `userFromPayload` добавить вызов `resolveImpersonation(baseUser, null)`. Так тестовая имперсонация заработает в API-слое без изменения api-client. Для реальной IAM-имперсонации это не поможет — сессии нет.
**Вывод:** Вариант A решает обе ситуации (тестовую и реальную). Вариант B — только тестовую.
### Вопрос 3: Как сделать так, чтобы `created_by` показывал целевой email?
При тестовой имперсонации:
- `resolveImpersonation` возвращает `email = impTarget`
- `req.user.email = impTarget`
- `createEntry(company.id, value, comment, req.user.email, req.user.originalUserEmail)`
`created_by = impTarget`
`impersonated_by = impOriginal`
Условие: `resolveImpersonation` должна отработать в API-слое (Вариант A или B из вопроса 2).
При реальной IAM-имперсонации: `iamData.email` из `fetchIamUser` — это email целевого пользователя? Или администратора? Нужно проверить. Если это email администратора (как можно предположить из логики KC), то:
- `session.user.email` = email администратора
- `session.user.isImpersonated = true`
- `session.user.originalUserEmail` = email кого имперсонируют (целевой)
Тогда `created_by` должен быть `originalUserEmail`, а `impersonated_by``email` (администратор). Это **обратная** логика по сравнению с тестовой! Нужно прояснить с IAM командой до реализации.
### Вопрос 4: Нужно ли обновлять сессию при изменении статуса имперсонации?
**Нет. Только при перелогине.**
Причины:
1. IAM-имперсонация начинается через `deck.ngcloud.ru` (отдельный портал), а не через наше приложение — мы не получаем уведомлений
2. Access-токен от Keycloak имеет ограниченное время жизни; при refresh token мы можем перечитать IAM-статус
3. Постоянные вызовы `fetchIamUser` на каждый запрос — лишняя нагрузка и latency
**Рекомендация:** перечитывать `fetchIamUser` при refresh-токена (если будет реализован механизм refresh) или добавить кнопку «Обновить профиль» которая делает перелогин.
**Альтернативно:** вызывать `fetchIamUser` раз в N минут (например, при каждом GET /) и обновлять `session.user`. Но это усложняет код.
### Вопрос 5: Есть ли более простой способ?
Да. Самый простой вариант, который решает проблему сейчас:
**«Embed impersonation in Bearer»** — не менять архитектуру, а в `ui/api-client.js` добавить один параметр `impersonation` и передавать его в заголовках. В `bearerMiddleware` добавить 5 строк чтения этих заголовков с проверкой `req.socket.remoteAddress`. Никакого рефакторинга `resolveImpersonation` не нужно.
```
Сейчас: UI-resolveUser строит req.user → API-client игнорирует его → bearerMiddleware строит свой req.user
Исправление: UI-resolveUser строит req.user → API-client добавляет X-Imp-* заголовки → bearerMiddleware читает их
```
Это минимальное изменение в двух файлах:
- `ui/api-client.js` — добавить extraHeaders параметр
- `ui/routes/entries.js` — передавать `req.user` в api.post/patch/delete
- `src/auth.js` `createBearerMiddleware` — читать X-Imp-* от localhost
---
## 3. Предлагаемая архитектура (без рефакторинга ради рефакторинга)
### Минимальный рабочий вариант (рекомендуется)
```
src/auth.js
└── resolveImpersonation(baseUser, sessionUser) ← новая функция, экспорт
└── createBearerMiddleware:
после userFromPayload → читать X-Imp-* от localhost → перезаписать req.user
ui/api-client.js
└── apiRequest(method, path, token, body, impersonation) ← новый параметр
если impersonation.isImpersonated → добавить X-Imp-* заголовки
ui/index.js (resolveUser)
└── вместо inline TEST IMP блока → вызов resolveImpersonation(baseUser, session.user)
└── req.impersonation = { isImpersonated, email, activeClientId, originalUserEmail }
ui/routes/entries.js (и остальные роуты с CRUD)
└── api.post(..., body, req.impersonation) ← передавать impersonation
└── api.patch(..., body, req.impersonation)
└── api.delete(..., null, req.impersonation)
```
### Что НЕ менять
- `src/queries.js` — уже правильно принимает `impersonatedBy`
- `src/api/routes/entries.js` — уже правильно берёт `req.user.email` и `req.user.originalUserEmail`
- `views/index.ejs` — баннер уже работает
- `src/routes/oidc.js``/callback` уже сохраняет IAM-данные в сессию
---
## 4. Методика тестирования
### Тест 1: Тестовая имперсонация (env) — основной
**Подготовка:**
```bash
IMPERSONATION_ORIGINAL=ntazetdinov@nubes.ru
IMPERSONATION_TARGET=test@test.ru
IMPERSONATION_COMPANY=WZ01325
```
**Шаги и ожидаемые результаты:**
| # | Действие | Ожидаемый результат |
|---|----------|---------------------|
| 1 | Войти как `ntazetdinov@nubes.ru` | Перенаправление на `/` |
| 2 | Открыть `/` | Жёлтый баннер: «Режим имперсонации — вы: ntazetdinov@nubes.ru» |
| 3 | Компания в UI | Показывает WZ01325 (не WZ01112) |
| 4 | Добавить запись `1.2.3.4` | 201 Created |
| 5 | `SELECT created_by FROM whitelist_entries WHERE value_cidr='1.2.3.4/32'` | `test@test.ru` |
| 6 | `SELECT user_email, impersonated_by FROM audit_log ORDER BY id DESC LIMIT 1` | `user_email=test@test.ru`, `impersonated_by=ntazetdinov@nubes.ru` |
| 7 | Удалить ENV, перелогиниться | Нет баннера, CRUD пишет `ntazetdinov@nubes.ru` в `created_by` |
### Тест 2: Реальная IAM-имперсонация
**Подготовка:** на стенде с активированным OIDC, tech-токен WZ01112.
**Шаги:**
| # | Действие | Ожидаемый результат |
|---|----------|---------------------|
| 1 | `POST /api/v1/impersonation/start` с tech-токеном | 200 OK |
| 2 | `GET /auth/user` с access-токеном целевого пользователя | `is_impersonated: true`, `originalUserEmail` заполнен |
| 3 | Целевой пользователь логинится → `/callback``fetchIamUser` | `session.user.isImpersonated=true` |
| 4 | UI показывает баннер | ✓ |
| 5 | CRUD — проверить `created_by` и `impersonated_by` в БД | Целевой email / оригинальный email |
⚠️ **Критический вопрос перед реализацией:** уточнить у IAM-команды, чей email содержит `iamData.email` при `is_impersonated=true` — администратора или целевого пользователя? От этого зависит, как назначать `created_by` и `impersonated_by`.
### Тест 3: Изоляция компаний при имперсонации
| # | Действие | Ожидаемый результат |
|---|----------|---------------------|
| 1 | Imp → WZ01325, добавить запись | Создаётся в WZ01325 |
| 2 | Переключить на WZ88888 (если есть) | Записи WZ01325 НЕ видны |
| 3 | Выйти из имперсонации | Записи своей компании, не WZ01325 |
### Тест 4: Аудит
```sql
-- После CRUD с имперсонацией
SELECT user_email, impersonated_by, action, new_value
FROM audit_log
ORDER BY created_at DESC
LIMIT 5;
```
Ожидаемо:
- `user_email` = целевой email (`IMPERSONATION_TARGET`)
- `impersonated_by` = оригинальный email (`IMPERSONATION_ORIGINAL`)
- Без имперсонации: `impersonated_by = NULL`
### Тест 5: Граничные случаи
| Сценарий | Ожидаемое поведение |
|----------|---------------------|
| `TARGET = ORIGINAL` | Баннер есть, данные те же, `impersonated_by = NULL` (или = `user_email`) |
| Нет `IMPERSONATION_COMPANY` | Своя компания (`activeClientId` из сессии) |
| Нет `IMPERSONATION_TARGET` | Свой email (нет подмены) |
| Toggle «Администратор» во время имперсонации | `adminMode` работает независимо; баннер остаётся |
| `/export` во время имперсонации | Данные WZ01325 (имперсонируемой компании), не WZ01112 |
| Истёк токен во время имперсонации | Редирект на `/login`, после перелогина — имперсонация активна снова (env) |
---
## 5. Итоговые выводы
1. **Корневая причина:** API-слой получает Bearer-токен без сессионного контекста — это не баг архитектуры, это осознанное разделение слоёв. Но тестовая имперсонация не учитывает эту границу.
2. **Минимальное исправление:** передавать `X-Imp-*` заголовки из api-client (UI → API, только localhost), читать в `bearerMiddleware`. Это 3 файла, ~20 строк.
3. **Единая функция `resolveImpersonation`:** полезна для устранения дублирования между `ui/index.js` и будущим `bearerMiddleware`, но не является обязательной для корректной работы.
4. **IAM реальная имперсонация:** нужно уточнить семантику полей IAM API (`iamData.email` — чей email?) перед реализацией.
5. **Сессия при смене статуса:** обновлять только при перелогине — это правильно и достаточно.
6. **Приоритет:** IAM над env — правильное решение. При реальной имперсонации `sessionUser.isImpersonated=true` должен перекрывать любые env-настройки.