From c6c9d01cd76b7689bc5dee41f057de1c0f139c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 30 May 2026 09:09:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20admin-=D1=80=D0=BE=D0=BB=D1=8C=20(isAdm?= =?UTF-8?q?in,=20requireAdmin),=20getCompanyById,=20getAllCompanies,=20set?= =?UTF-8?q?Limit,=20getAudit=20=D1=81=20JOIN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth.js | 29 +++++++++++++++++++++- src/queries.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/auth.js b/src/auth.js index 90dd4a3..1e2a4c0 100644 --- a/src/auth.js +++ b/src/auth.js @@ -4,6 +4,10 @@ const crypto = require('crypto'); const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api'; const JWKS_URL = process.env.JWKS_URL || ''; +// clientId платформы, которому выдаётся роль администратора. +// Задаётся через .env, по умолчанию WZ01112 (Nubes) согласно ТЗ. +const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112'; + let keyPair = null; let jwks = null; @@ -36,16 +40,22 @@ function initAuth() { function createMiddleware() { return function authMiddleware(req, res, next) { + // DEV_MODE: обход авторизации для локальной разработки. + // Активен только если NODE_ENV != 'production' — в prod всегда требует токен. + // DEV_ADMIN=true позволяет тестировать admin-функционал в dev-режиме. if (process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production') { req.user = { email: 'dev@test.local', clientId: 'WZ01325', companyId: '00000000-0000-0000-0000-000000000000', companyName: 'DEV', + // DEV_ADMIN=true → имитировать admin-права в dev-окружении + isAdmin: process.env.DEV_ADMIN === 'true', }; return next(); } + // Токен извлекаем из httpOnly-cookie (браузер) или Authorization header (API) var token = (req.cookies && req.cookies.jwt) || (req.headers.authorization || '').replace('Bearer ', ''); @@ -63,6 +73,9 @@ function createMiddleware() { clientId: payload.ClientID, companyId: payload.company_id, companyName: payload.company_name || payload.ClientID, + // Роль admin определяется по clientId платформы согласно ТЗ. + // В проде: можно расширить проверкой отдельного claim из Keycloak. + isAdmin: payload.ClientID === ADMIN_CLIENT_ID, }; next(); } catch (e) { @@ -75,8 +88,22 @@ function createMiddleware() { }; } +/** + * Middleware-защита для admin-only маршрутов. + * Использовать после app.use(auth.middleware): + * app.get('/admin', requireAdmin, handler) + */ +function requireAdmin(req, res, next) { + if (!req.user || !req.user.isAdmin) { + return res.status(403).send('403 Forbidden: требуется роль администратора'); + } + next(); +} + function verifyJWT(token) { if (JWKS_URL) { + // TODO: реализовать верификацию через внешний JWKS-endpoint. + // Нужен URL от команды Keycloak: JWKS_URL=https://auth-api.../jwks throw new Error('JWKS_URL verification not implemented yet'); } return jwt.verify(token, keyPair.publicKey, { @@ -106,4 +133,4 @@ function issueJWT(claims) { ); } -module.exports = { initAuth }; +module.exports = { initAuth, requireAdmin }; diff --git a/src/queries.js b/src/queries.js index 90094dd..fda0b8e 100644 --- a/src/queries.js +++ b/src/queries.js @@ -141,6 +141,58 @@ async function deleteEntry(entryId, companyId, userEmail) { } } +// ── Admin: компании ── + +/** + * Получить одну компанию по числовому id (primary key). + * Используется когда admin работает с чужой компанией: + * нам не нужно getOrCreateCompany (которая привязывается к clientId пользователя). + * + * @param {number} id — companies.id + * @returns {object|null} строка из таблицы companies или null + */ +async function getCompanyById(id) { + const res = await pool.query('SELECT * FROM companies WHERE id = $1', [id]); + return res.rows[0] || null; +} + +/** + * Получить все компании с количеством активных записей каждой. + * Используется на странице администратора: список компаний + статистика. + * LEFT JOIN гарантирует присутствие компаний с нулём записей. + * + * @returns {object[]} строки companies с доп. полем active_count + */ +async function getAllCompanies() { + const res = await pool.query(` + SELECT c.*, + COUNT(e.id)::int AS active_count + FROM companies c + LEFT JOIN whitelist_entries e + ON e.company_id = c.id AND e.deleted_at IS NULL + GROUP BY c.id + ORDER BY c.name, c.client_id + `); + return res.rows; +} + +/** + * Задать индивидуальный лимит для компании. + * newLimit = null → сброс на глобальный дефолт (DEFAULT_LIMIT из .env / 15). + * newLimit = число → индивидуальный лимит, переопределяет глобальный. + * + * ТЗ 4.5: «снижение лимита не удаляет существующие записи, но блокирует создание новых». + * + * @param {number} companyId — companies.id + * @param {number|null} newLimit — новый лимит или null для сброса + */ +async function setLimit(companyId, newLimit) { + await pool.query( + 'UPDATE companies SET custom_limit = $1, updated_at = NOW() WHERE id = $2', + [newLimit, companyId] + ); +} + // ── Export ── async function getExportCIDRs(companyId = null) { @@ -165,13 +217,20 @@ async function logAudit(userEmail, companyId, action, oldValue, newValue, entryI } async function getAudit(companyId = null) { - let sql = 'SELECT * FROM audit_log'; + // JOIN с companies чтобы выводить human-readable название компании в UI. + // Лимит 500 строк — достаточно для оперативного мониторинга. + // В проде можно добавить пагинацию. + let sql = ` + SELECT a.*, c.name AS company_name, c.client_id + FROM audit_log a + JOIN companies c ON c.id = a.company_id + `; const params = []; if (companyId) { - sql += ' WHERE company_id = $1'; + sql += ' WHERE a.company_id = $1'; params.push(companyId); } - sql += ' ORDER BY created_at DESC LIMIT 500'; + sql += ' ORDER BY a.created_at DESC LIMIT 500'; return (await pool.query(sql, params)).rows; } @@ -180,4 +239,6 @@ module.exports = { listEntries, createEntry, updateEntry, deleteEntry, getExportCIDRs, getAudit, + // admin + getCompanyById, getAllCompanies, setLimit, };