feat: admin-роль (isAdmin, requireAdmin), getCompanyById, getAllCompanies, setLimit, getAudit с JOIN

This commit is contained in:
2026-05-30 09:09:24 +03:00
parent cda0b085e6
commit c6c9d01cd7
2 changed files with 92 additions and 4 deletions
+28 -1
View File
@@ -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 };
+64 -3
View File
@@ -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,
};