feat: admin-роль (isAdmin, requireAdmin), getCompanyById, getAllCompanies, setLimit, getAudit с JOIN
This commit is contained in:
+28
-1
@@ -4,6 +4,10 @@ const crypto = require('crypto');
|
|||||||
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
|
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
|
||||||
const JWKS_URL = process.env.JWKS_URL || '';
|
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 keyPair = null;
|
||||||
let jwks = null;
|
let jwks = null;
|
||||||
|
|
||||||
@@ -36,16 +40,22 @@ function initAuth() {
|
|||||||
|
|
||||||
function createMiddleware() {
|
function createMiddleware() {
|
||||||
return function authMiddleware(req, res, next) {
|
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') {
|
if (process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production') {
|
||||||
req.user = {
|
req.user = {
|
||||||
email: 'dev@test.local',
|
email: 'dev@test.local',
|
||||||
clientId: 'WZ01325',
|
clientId: 'WZ01325',
|
||||||
companyId: '00000000-0000-0000-0000-000000000000',
|
companyId: '00000000-0000-0000-0000-000000000000',
|
||||||
companyName: 'DEV',
|
companyName: 'DEV',
|
||||||
|
// DEV_ADMIN=true → имитировать admin-права в dev-окружении
|
||||||
|
isAdmin: process.env.DEV_ADMIN === 'true',
|
||||||
};
|
};
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Токен извлекаем из httpOnly-cookie (браузер) или Authorization header (API)
|
||||||
var token = (req.cookies && req.cookies.jwt) ||
|
var token = (req.cookies && req.cookies.jwt) ||
|
||||||
(req.headers.authorization || '').replace('Bearer ', '');
|
(req.headers.authorization || '').replace('Bearer ', '');
|
||||||
|
|
||||||
@@ -63,6 +73,9 @@ function createMiddleware() {
|
|||||||
clientId: payload.ClientID,
|
clientId: payload.ClientID,
|
||||||
companyId: payload.company_id,
|
companyId: payload.company_id,
|
||||||
companyName: payload.company_name || payload.ClientID,
|
companyName: payload.company_name || payload.ClientID,
|
||||||
|
// Роль admin определяется по clientId платформы согласно ТЗ.
|
||||||
|
// В проде: можно расширить проверкой отдельного claim из Keycloak.
|
||||||
|
isAdmin: payload.ClientID === ADMIN_CLIENT_ID,
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
} catch (e) {
|
} 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) {
|
function verifyJWT(token) {
|
||||||
if (JWKS_URL) {
|
if (JWKS_URL) {
|
||||||
|
// TODO: реализовать верификацию через внешний JWKS-endpoint.
|
||||||
|
// Нужен URL от команды Keycloak: JWKS_URL=https://auth-api.../jwks
|
||||||
throw new Error('JWKS_URL verification not implemented yet');
|
throw new Error('JWKS_URL verification not implemented yet');
|
||||||
}
|
}
|
||||||
return jwt.verify(token, keyPair.publicKey, {
|
return jwt.verify(token, keyPair.publicKey, {
|
||||||
@@ -106,4 +133,4 @@ function issueJWT(claims) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { initAuth };
|
module.exports = { initAuth, requireAdmin };
|
||||||
|
|||||||
+64
-3
@@ -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 ──
|
// ── Export ──
|
||||||
|
|
||||||
async function getExportCIDRs(companyId = null) {
|
async function getExportCIDRs(companyId = null) {
|
||||||
@@ -165,13 +217,20 @@ async function logAudit(userEmail, companyId, action, oldValue, newValue, entryI
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getAudit(companyId = null) {
|
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 = [];
|
const params = [];
|
||||||
if (companyId) {
|
if (companyId) {
|
||||||
sql += ' WHERE company_id = $1';
|
sql += ' WHERE a.company_id = $1';
|
||||||
params.push(companyId);
|
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;
|
return (await pool.query(sql, params)).rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,4 +239,6 @@ module.exports = {
|
|||||||
listEntries, createEntry, updateEntry, deleteEntry,
|
listEntries, createEntry, updateEntry, deleteEntry,
|
||||||
getExportCIDRs,
|
getExportCIDRs,
|
||||||
getAudit,
|
getAudit,
|
||||||
|
// admin
|
||||||
|
getCompanyById, getAllCompanies, setLimit,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user