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

20 KiB
Raw Blame History

Анализ архитектуры имперсонации — 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.isImpersonatedreq.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-слое сессии не видит. Единое место вычисления — хорошо, но единое место применения требует двух вызовов с разными источниками данных.

Предлагаемая сигнатура:

/**
 * Вычисляет итоговые параметры имперсонации.
 * @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-слое: bearerMiddlewareuserFromPayload → нет сессии → нет originalUserEmail.

Два варианта передачи:

Вариант A (рекомендуется): кастомные заголовки из api-client В ui/api-client.js при вызове API добавлять заголовки, если у req.user есть флаг имперсонации:

// В 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:

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_byemail (администратор). Это обратная логика по сравнению с тестовой! Нужно прояснить с 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) — основной

Подготовка:

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 Целевой пользователь логинится → /callbackfetchIamUser 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: Аудит

-- После 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-настройки.