From 68ea44a81df483e54b413d5799dbabe88f2b2fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 11 Jun 2026 20:17:07 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20X-Imp-*=20headers=20bridge=20UI=E2=86=92?= =?UTF-8?q?API=20impersonation=20+=20timezone=20GMT+4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth.js | 16 ++++++++++++++++ ui/api-client.js | 26 +++++++++++++++++++++----- ui/routes/entries.js | 16 ++++++++++------ views/audit.ejs | 2 +- views/index.ejs | 4 ++-- 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/auth.js b/src/auth.js index 2c9de90..358f3c1 100644 --- a/src/auth.js +++ b/src/auth.js @@ -445,6 +445,22 @@ function createBearerMiddleware(isOidc, devMode) { try { const payload = verifyAnyToken(bearer, devMode); req.user = userFromPayload(payload); + + // Проброс контекста имперсонации из UI-слоя (X-Imp-* заголовки) + // Устанавливаются ui/api-client.js → api.impHeaders() + const impEmail = req.headers['x-imp-email'] || ''; + const impOrig = req.headers['x-imp-originalemail'] || ''; + const impClientId = req.headers['x-imp-clientid'] || ''; + if (impEmail) { + req.user.email = impEmail; + req.user.originalUserEmail = impOrig; + req.user.isImpersonated = true; + if (impClientId) { + req.user.clientId = impClientId; + req.user.activeClientId = impClientId; + } + } + return next(); } catch (e) { return res.status(401).json({ error: 'Invalid token: ' + e.message }); diff --git a/ui/api-client.js b/ui/api-client.js index 9caf734..52f9c0a 100644 --- a/ui/api-client.js +++ b/ui/api-client.js @@ -22,7 +22,7 @@ const API_BASE = process.env.API_BASE || 'http://localhost:' + (process.env.PORT * @param {object} [body] — JSON body (для POST/PATCH) * @returns {Promise<{status: number, data: any}>} */ -function apiRequest(method, path, token, body) { +function apiRequest(method, path, token, body, impHeaders) { return new Promise((resolve, reject) => { const url = new URL(API_BASE + path); const lib = url.protocol === 'https:' ? https : http; @@ -38,6 +38,12 @@ function apiRequest(method, path, token, body) { 'Accept': 'application/json', }, }; + // Проброс контекста имперсонации из UI в API-слой + if (impHeaders) { + if (impHeaders.impEmail) opts.headers['X-Imp-Email'] = impHeaders.impEmail; + if (impHeaders.impOriginalEmail) opts.headers['X-Imp-OriginalEmail'] = impHeaders.impOriginalEmail; + if (impHeaders.impClientId) opts.headers['X-Imp-ClientId'] = impHeaders.impClientId; + } if (bodyStr) { opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Length'] = Buffer.byteLength(bodyStr); @@ -65,13 +71,23 @@ function apiRequest(method, path, token, body) { // Хелперы — один вызов на метод const api = { - get: (path, token) => apiRequest('GET', path, token), - post: (path, token, body) => apiRequest('POST', path, token, body), - patch: (path, token, body) => apiRequest('PATCH', path, token, body), - delete: (path, token) => apiRequest('DELETE', path, token), + get: (path, token, imp) => apiRequest('GET', path, token, null, imp), + post: (path, token, body, imp) => apiRequest('POST', path, token, body, imp), + patch: (path, token, body, imp) => apiRequest('PATCH', path, token, body, imp), + delete: (path, token, imp) => apiRequest('DELETE', path, token, null, imp), // Достать Bearer из сессии token: (req) => req.session && req.session.token, + + // Построить заголовки имперсонации из req.user (для проброса в API-слой) + impHeaders: (req) => { + if (!req.user || !req.user.isImpersonated) return null; + return { + impEmail: req.user.email, + impOriginalEmail: req.user.originalUserEmail, + impClientId: req.user.activeClientId || req.user.clientId, + }; + }, }; module.exports = api; diff --git a/ui/routes/entries.js b/ui/routes/entries.js index c1fd6c9..b64c1cd 100644 --- a/ui/routes/entries.js +++ b/ui/routes/entries.js @@ -38,6 +38,7 @@ function createRouter() { // GET / — список записей router.get('/', async (req, res) => { const token = api.token(req); + const imp = api.impHeaders(req); // ── Переключатель компании через IAM ──────────────────────────────── const switchTo = (req.query.switchTo || '').trim(); @@ -78,7 +79,7 @@ function createRouter() { let entries = [], limit = 15, selectedCompany = null, companies = []; if (req.user.adminMode) { - const cr = await api.get('/api/v1/companies', token); + const cr = await api.get('/api/v1/companies', token, imp); companies = cr.data.companies || []; const companyId = req.query.company ? parseInt(req.query.company, 10) : null; @@ -91,7 +92,7 @@ function createRouter() { if (selectedCompany) { const includeDeleted = req.query.includeDeleted === 'true' ? '&includeDeleted=true' : ''; - const er = await api.get('/api/v1/entries?company=' + selectedCompany.id + includeDeleted, token); + const er = await api.get('/api/v1/entries?company=' + selectedCompany.id + includeDeleted, token, imp); entries = er.data.entries || []; limit = er.data.limit || 15; } @@ -101,7 +102,7 @@ function createRouter() { const clientIdParam = req.user.allClientIds && req.user.allClientIds.length > 1 ? '?client_id=' + encodeURIComponent(activeClientId) : ''; - const er = await api.get('/api/v1/entries' + clientIdParam, token); + const er = await api.get('/api/v1/entries' + clientIdParam, token, imp); entries = er.data.entries || []; limit = er.data.limit || 15; } @@ -150,11 +151,12 @@ function createRouter() { // POST /add — создать запись router.post('/add', async (req, res) => { const token = api.token(req); + const imp = api.impHeaders(req); const { value, comment } = req.body; const cq = companyQuery(req); const back = backUrl(req); try { - const r = await api.post('/api/v1/entries' + cq, token, { value, comment }); + const r = await api.post('/api/v1/entries' + cq, token, { value, comment }, imp); if (r.status === 201) { const msg = r.data.wasNormalized ? 'Запись добавлена (CIDR нормализован до ' + r.data.entry.value_cidr + ')' @@ -175,11 +177,12 @@ function createRouter() { // POST /edit/:id — обновить запись router.post('/edit/:id', async (req, res) => { const token = api.token(req); + const imp = api.impHeaders(req); const { value, comment } = req.body; const cq = companyQuery(req); const back = backUrl(req); try { - const r = await api.patch('/api/v1/entries/' + req.params.id + cq, token, { value, comment }); + const r = await api.patch('/api/v1/entries/' + req.params.id + cq, token, { value, comment }, imp); if (r.status === 200) { return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись обновлена')); } @@ -197,10 +200,11 @@ function createRouter() { // POST /delete/:id — удалить запись router.post('/delete/:id', async (req, res) => { const token = api.token(req); + const imp = api.impHeaders(req); const cq = companyQuery(req); const back = backUrl(req); try { - const r = await api.delete('/api/v1/entries/' + req.params.id + cq, token); + const r = await api.delete('/api/v1/entries/' + req.params.id + cq, token, imp); if (r.status === 204) { return res.redirect(back + (back.includes('?') ? '&' : '?') + 'success=' + encodeURIComponent('Запись удалена')); } diff --git a/views/audit.ejs b/views/audit.ejs index d81321e..1c20749 100644 --- a/views/audit.ejs +++ b/views/audit.ejs @@ -133,7 +133,7 @@ <%= new Date(r.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', year: 'numeric', - hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Moscow' + hour: '2-digit', minute: '2-digit', timeZone: 'Etc/GMT-4' }) %> <%= r.user_email %> diff --git a/views/index.ejs b/views/index.ejs index cb80502..2e4ef72 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -337,11 +337,11 @@ <%= e.value_cidr %> <%= e.comment || '—' %> <%= e.created_by %> - <%= new Date(e.created_at).toLocaleDateString('ru', {day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit',timeZone:'Europe/Moscow'}) %> + <%= new Date(e.created_at).toLocaleDateString('ru', {day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit',timeZone:'Etc/GMT-4'}) %> <%= e.updated_at - ? new Date(e.updated_at).toLocaleDateString('ru', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit',timeZone:'Europe/Moscow'}) + ? new Date(e.updated_at).toLocaleDateString('ru', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit',timeZone:'Etc/GMT-4'}) : '—' %>