# Анализ архитектуры имперсонации — 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 (без сессионных данных!) [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-настройки.