feat: full IAM impersonation support — read-only + original user + canWrite

- src/auth.js: all impersonation fields + permissions.can_write
- src/routes/oidc.js: save all fields to session
- ui/index.js: canWrite, originalUserEmail in req.user
- ui/routes/entries.js: read-only guard on POST/PATCH/DELETE
- docs/PLAN-impersonation.md: updated with implemented items
This commit is contained in:
2026-06-11 15:54:56 +04:00
parent a2cfd2c10f
commit 9a331eec82
6 changed files with 38 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ipwhitelist", "name": "ipwhitelist",
"version": "0.5.52", "version": "0.5.53",
"description": "IP WhiteList microservice for cloud provider", "description": "IP WhiteList microservice for cloud provider",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+9 -1
View File
@@ -312,6 +312,8 @@ async function fetchIamUser(token) {
const profiles = raw.profiles || []; const profiles = raw.profiles || [];
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {}; const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
const ui = raw.userInfo || {}; const ui = raw.userInfo || {};
const imp = raw.impersonation || {};
const perms = raw.permissions || {};
return { return {
email: ui.email || '', email: ui.email || '',
@@ -321,7 +323,13 @@ async function fetchIamUser(token) {
companyId: ui.companyId || '', companyId: ui.companyId || '',
companyName: ui.company || activeProfile.company_name || '', companyName: ui.company || activeProfile.company_name || '',
isAdmin: !!ui.isAdmin, isAdmin: !!ui.isAdmin,
isImpersonated: !!(raw.impersonation && raw.impersonation.is_impersonated), canWrite: perms.can_write !== false,
isImpersonated: !!(imp.is_impersonated),
impersonationType: imp.type || null,
originalUserEmail: imp.originalUserEmail || '',
originalUserFullName: imp.originalUserFullName || '',
originalUserCompany: imp.originalUserCompany || '',
impersonatedCompanyId: imp.impersonatedCompanyId || '',
fio: ui.fio || null, fio: ui.fio || null,
profiles, // полный массив для UI profiles, // полный массив для UI
raw, // сырой ответ (для отладки) raw, // сырой ответ (для отладки)
+5
View File
@@ -73,7 +73,12 @@ function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimit
companyId: iamData.companyId, companyId: iamData.companyId,
companyName: iamData.companyName, companyName: iamData.companyName,
isAdmin: iamData.isAdmin, isAdmin: iamData.isAdmin,
canWrite: iamData.canWrite,
isImpersonated: iamData.isImpersonated, isImpersonated: iamData.isImpersonated,
impersonationType: iamData.impersonationType,
originalUserEmail: iamData.originalUserEmail,
originalUserFullName: iamData.originalUserFullName,
originalUserCompany: iamData.originalUserCompany,
fio: iamData.fio, fio: iamData.fio,
profiles: iamData.profiles, profiles: iamData.profiles,
}; };
+4
View File
@@ -64,6 +64,7 @@ function createUiRouter({ auth, MOCK_USERS, authLimiter }) {
const canAdminMode = !!(iamAdmin && activeClientId === ADMIN_CLIENT_ID) const canAdminMode = !!(iamAdmin && activeClientId === ADMIN_CLIENT_ID)
|| (isNail && activeClientId === ADMIN_CLIENT_ID); || (isNail && activeClientId === ADMIN_CLIENT_ID);
const sessionIsImpersonated = req.session && req.session.user && req.session.user.isImpersonated; const sessionIsImpersonated = req.session && req.session.user && req.session.user.isImpersonated;
const sessionCanWrite = req.session && req.session.user ? req.session.user.canWrite : true;
req.user = { req.user = {
clientId: rawClientId, clientId: rawClientId,
allClientIds, allClientIds,
@@ -72,7 +73,10 @@ function createUiRouter({ auth, MOCK_USERS, authLimiter }) {
companyName: payload.company_name || payload.companyName || activeClientId, companyName: payload.company_name || payload.companyName || activeClientId,
email: req.session?.user?.email || payload.email || payload.login || activeClientId + '@unknown', email: req.session?.user?.email || payload.email || payload.login || activeClientId + '@unknown',
isAdmin: iamAdmin || isNail, isAdmin: iamAdmin || isNail,
canWrite: sessionCanWrite,
isImpersonated: !!sessionIsImpersonated, isImpersonated: !!sessionIsImpersonated,
originalUserEmail: req.session?.user?.originalUserEmail || '',
originalUserFullName: req.session?.user?.originalUserFullName || '',
canAdminMode, canAdminMode,
adminMode: !!(req.session && req.session.adminMode), adminMode: !!(req.session && req.session.adminMode),
}; };
+7
View File
@@ -120,6 +120,8 @@ function createRouter() {
selectedCompany, selectedCompany,
canAdminMode: req.user.canAdminMode, canAdminMode: req.user.canAdminMode,
isImpersonated: req.user.isImpersonated, isImpersonated: req.user.isImpersonated,
originalUserEmail: req.user.originalUserEmail,
canWrite: req.user.canWrite,
allClientIds: hasMultiple ? req.user.allClientIds : null, allClientIds: hasMultiple ? req.user.allClientIds : null,
activeClientId: req.user.activeClientId || req.user.clientId, activeClientId: req.user.activeClientId || req.user.clientId,
profiles, // из IAM — для UI переключателя profiles, // из IAM — для UI переключателя
@@ -136,6 +138,8 @@ function createRouter() {
user: req.user, isAdmin: req.user.isAdmin, adminMode: req.user.adminMode, user: req.user, isAdmin: req.user.isAdmin, adminMode: req.user.adminMode,
canAdminMode: req.user.canAdminMode, canAdminMode: req.user.canAdminMode,
isImpersonated: req.user.isImpersonated, isImpersonated: req.user.isImpersonated,
originalUserEmail: req.user.originalUserEmail,
canWrite: req.user.canWrite,
companies: [], selectedCompany: null, companies: [], selectedCompany: null,
allClientIds: null, activeClientId: null, allClientIds: null, activeClientId: null,
error: 'Ошибка загрузки: ' + e.message, error: 'Ошибка загрузки: ' + e.message,
@@ -147,6 +151,7 @@ function createRouter() {
// POST /add — создать запись // POST /add — создать запись
router.post('/add', async (req, res) => { router.post('/add', async (req, res) => {
if (req.user && !req.user.canWrite) return res.status(403).send('Read-only mode');
const token = api.token(req); const token = api.token(req);
const { value, comment } = req.body; const { value, comment } = req.body;
const cq = companyQuery(req); const cq = companyQuery(req);
@@ -172,6 +177,7 @@ function createRouter() {
// POST /edit/:id — обновить запись // POST /edit/:id — обновить запись
router.post('/edit/:id', async (req, res) => { router.post('/edit/:id', async (req, res) => {
if (req.user && !req.user.canWrite) return res.status(403).send('Read-only mode');
const token = api.token(req); const token = api.token(req);
const { value, comment } = req.body; const { value, comment } = req.body;
const cq = companyQuery(req); const cq = companyQuery(req);
@@ -194,6 +200,7 @@ function createRouter() {
// POST /delete/:id — удалить запись // POST /delete/:id — удалить запись
router.post('/delete/:id', async (req, res) => { router.post('/delete/:id', async (req, res) => {
if (req.user && !req.user.canWrite) return res.status(403).send('Read-only mode');
const token = api.token(req); const token = api.token(req);
const cq = companyQuery(req); const cq = companyQuery(req);
const back = backUrl(req); const back = backUrl(req);
+12
View File
@@ -185,6 +185,14 @@
<% if (typeof isImpersonated !== 'undefined' && isImpersonated) { %> <% if (typeof isImpersonated !== 'undefined' && isImpersonated) { %>
<div style="background:#fef3c7;border:1px solid #d97706;color:#92400e;padding:.5rem 1rem;border-radius:8px;margin-bottom:.5rem;font-size:.85rem;"> <div style="background:#fef3c7;border:1px solid #d97706;color:#92400e;padding:.5rem 1rem;border-radius:8px;margin-bottom:.5rem;font-size:.85rem;">
⚠️ Режим имперсонации — данные сохраняются от имени <strong><%= user.clientId %></strong> ⚠️ Режим имперсонации — данные сохраняются от имени <strong><%= user.clientId %></strong>
<% if (typeof originalUserEmail !== 'undefined' && originalUserEmail) { %>
(вы: <%= originalUserEmail %>)
<% } %>
</div>
<% } %>
<% if (typeof canWrite !== 'undefined' && !canWrite) { %>
<div style="background:#fee2e2;border:1px solid #dc2626;color:#991b1b;padding:.5rem 1rem;border-radius:8px;margin-bottom:.5rem;font-size:.85rem;">
🔒 Режим только для чтения — редактирование запрещено
</div> </div>
<% } %> <% } %>
@@ -279,6 +287,7 @@
</div> </div>
<!-- Форма добавления --> <!-- Форма добавления -->
<% if (typeof canWrite === 'undefined' || canWrite) { %>
<div class="card"> <div class="card">
<div class="card-header">Добавить адрес</div> <div class="card-header">Добавить адрес</div>
<div class="card-body"> <div class="card-body">
@@ -308,6 +317,7 @@
</form> </form>
</div> </div>
</div> </div>
<% } %>
<!-- Таблица --> <!-- Таблица -->
<div class="card"> <div class="card">
@@ -341,6 +351,7 @@
: '—' %> : '—' %>
</td> </td>
<td class="text-center" style="white-space:nowrap;"> <td class="text-center" style="white-space:nowrap;">
<% if (typeof canWrite === 'undefined' || canWrite) { %>
<% <%
// data-* атрибуты для JS-заполнения modal без inline-onclick с параметрами. // data-* атрибуты для JS-заполнения modal без inline-onclick с параметрами.
// Так как комментарий может содержать кавычки — EJS экранирует их в HTML. // Так как комментарий может содержать кавычки — EJS экранирует их в HTML.
@@ -365,6 +376,7 @@
<input type="hidden" name="_csrf" value="<%= csrfToken %>"> <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-danger">Удалить</button> <button class="btn btn-danger">Удалить</button>
</form> </form>
<% } %>
</td> </td>
</tr> </tr>
<% }) %> <% }) %>