- oidc.js: remove || true — state check now works in all environments - auth.js: wrap returnTo in safeReturn (was unprotected on line 166)
20 KiB
Анализ архитектуры имперсонации — 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.userresolveUserв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-слое сессии не видит. Единое место вычисления — хорошо, но единое место применения требует двух вызовов с разными источниками данных.
Предлагаемая сигнатура:
/**
* Вычисляет итоговые параметры имперсонации.
* @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 есть флаг имперсонации:
// В 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 = impTargetreq.user.email = impTargetcreateEntry(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 = truesession.user.originalUserEmail= email кого имперсонируют (целевой)
Тогда created_by должен быть originalUserEmail, а impersonated_by — email (администратор). Это обратная логика по сравнению с тестовой! Нужно прояснить с IAM командой до реализации.
Вопрос 4: Нужно ли обновлять сессию при изменении статуса имперсонации?
Нет. Только при перелогине.
Причины:
- IAM-имперсонация начинается через
deck.ngcloud.ru(отдельный портал), а не через наше приложение — мы не получаем уведомлений - Access-токен от Keycloak имеет ограниченное время жизни; при refresh token мы можем перечитать IAM-статус
- Постоянные вызовы
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/deletesrc/auth.jscreateBearerMiddleware— читать 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— уже правильно принимаетimpersonatedBysrc/api/routes/entries.js— уже правильно берётreq.user.emailиreq.user.originalUserEmailviews/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 | Целевой пользователь логинится → /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: Аудит
-- После 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. Итоговые выводы
-
Корневая причина: API-слой получает Bearer-токен без сессионного контекста — это не баг архитектуры, это осознанное разделение слоёв. Но тестовая имперсонация не учитывает эту границу.
-
Минимальное исправление: передавать
X-Imp-*заголовки из api-client (UI → API, только localhost), читать вbearerMiddleware. Это 3 файла, ~20 строк. -
Единая функция
resolveImpersonation: полезна для устранения дублирования междуui/index.jsи будущимbearerMiddleware, но не является обязательной для корректной работы. -
IAM реальная имперсонация: нужно уточнить семантику полей IAM API (
iamData.email— чей email?) перед реализацией. -
Сессия при смене статуса: обновлять только при перелогине — это правильно и достаточно.
-
Приоритет: IAM над env — правильное решение. При реальной имперсонации
sessionUser.isImpersonated=trueдолжен перекрывать любые env-настройки.