v2: подробные комментарии во всех модулях — что, зачем, контракты
This commit is contained in:
+33
-12
@@ -1,25 +1,42 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — Админка (Dashboard, аудит, лимиты, все компании)
|
||||
// V2 — Админка
|
||||
//
|
||||
// Вход: req.isAdmin, req.email, req.clientId из resolveContext.
|
||||
// Только для админов (isAdmin=true).
|
||||
// Через crud/ для CRUD, через db/queries для admin-specific (аудит, лимиты).
|
||||
// ЭТО: Express-роутер для администратора (isAdmin=true).
|
||||
// НЕ: доступно обычным юзерам (middleware проверяет req.isAdmin).
|
||||
//
|
||||
// ЗАЧЕМ:
|
||||
// 1. Видеть ВСЕ компании и их статистику (дашборд).
|
||||
// 2. Смотреть аудит любой компании.
|
||||
// 3. Устанавливать персональные лимиты.
|
||||
// 4. Просматривать записи любой компании.
|
||||
//
|
||||
// ДАННЫЕ:
|
||||
// crud.list(clientId) — записи компании (через API-слой)
|
||||
// q.getAllCompanies() — все компании со счётчиками
|
||||
// q.getAudit(companyId) — аудит (null = все компании)
|
||||
// q.setLimit(id, limit) — персональный лимит
|
||||
//
|
||||
// МАРШРУТЫ:
|
||||
// GET /admin — дашборд: таблица всех компаний
|
||||
// GET /admin/audit — аудит (?companyId=X — фильтр)
|
||||
// POST /admin/limit — установить лимит (body: companyId, limit)
|
||||
// GET /admin/entries — записи компании (?companyId=X)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const express = require('express');
|
||||
const q = require('../db/queries');
|
||||
const crud = require('../crud');
|
||||
const q = require('../db/queries'); // admin-specific: аудит, лимиты, все компании
|
||||
const crud = require('../crud'); // CRUD для просмотра записей
|
||||
|
||||
function createAdminRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// ── middleware: только админы ───────────────────────────────────────────
|
||||
// ── middleware: только админы ───────────────────────────────────────
|
||||
router.use((req, res, next) => {
|
||||
if (!req.isAdmin) return res.redirect('/v2/app');
|
||||
next();
|
||||
});
|
||||
|
||||
// ── GET / — дашборд: все компании ──────────────────────────────────────
|
||||
// ── GET / — дашборд: все компании ──────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const companies = await q.getAllCompanies();
|
||||
@@ -30,7 +47,7 @@ function createAdminRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /audit?companyId=X ─────────────────────────────────────────────
|
||||
// ── GET /audit?companyId=X — журнал действий ───────────────────────
|
||||
router.get('/audit', async (req, res) => {
|
||||
try {
|
||||
const companyId = req.query.companyId ? parseInt(req.query.companyId) : null;
|
||||
@@ -42,7 +59,7 @@ function createAdminRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /limit ────────────────────────────────────────────────────────
|
||||
// ── POST /limit — установить персональный лимит ────────────────────
|
||||
router.post('/limit', async (req, res) => {
|
||||
try {
|
||||
const companyId = parseInt(req.body.companyId);
|
||||
@@ -55,12 +72,13 @@ function createAdminRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /entries?companyId=X — записи любой компании ───────────────────
|
||||
// ── GET /entries?companyId=X — записи любой компании ───────────────
|
||||
router.get('/entries', async (req, res) => {
|
||||
try {
|
||||
const companyId = parseInt(req.query.companyId);
|
||||
const company = companyId ? await q.getCompanyById(companyId) : null;
|
||||
if (!company) return res.redirect('/v2/admin');
|
||||
// crud.list принимает clientId (W-номер), не внутренний id
|
||||
const { entries, used, limit } = await crud.list(company.client_id, req.query.deleted === '1');
|
||||
const version = require('../config').version;
|
||||
res.send(renderEntries({ entries, company, used, limit, includeDeleted: req.query.deleted === '1', version }));
|
||||
@@ -72,7 +90,9 @@ function createAdminRouter() {
|
||||
return router;
|
||||
}
|
||||
|
||||
// ── HTML-рендеринг ─────────────────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HTML-рендеринг (временный, будет заменён на EJS)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function renderDashboard({ companies, user, version }) {
|
||||
const rows = companies.map(c => `
|
||||
@@ -183,6 +203,7 @@ function renderEntries({ entries, company, used, limit, includeDeleted, version
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// ── msgHtml() — скрипт для показа зелёных/красных плашек ──────────────
|
||||
function msgHtml() {
|
||||
return `<script>
|
||||
const p=new URLSearchParams(location.search);
|
||||
|
||||
+47
-376
@@ -1,392 +1,63 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Модуль аутентификации
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — Аутентификация через Keycloak OIDC + IAM
|
||||
//
|
||||
// Два источника данных:
|
||||
// 1. Keycloak OIDC — токены (access_token, id_token)
|
||||
// 2. IAM API — профиль пользователя (компании, роли, имперсонация)
|
||||
// ЭТО: OIDC-клиент для Keycloak.
|
||||
// НЕ: Express middleware (только функции).
|
||||
//
|
||||
// После вызова login() ни Keycloak, ни IAM больше не нужны.
|
||||
// Всё что требуется для сессии — в возвращаемом объекте sessionUser.
|
||||
// ПОТОК:
|
||||
// 1. buildAuthUrl() — формирует URL для редиректа на KC (/auth)
|
||||
// 2. KC → code на /callback
|
||||
// 3. exchangeCode(code) → tokens (access_token, id_token)
|
||||
// 4. fetchIamUser(token) → профиль из IAM API
|
||||
//
|
||||
// Экспорт:
|
||||
// login(code, oidcConfig, iamUrl) → { sessionUser, token, idToken }
|
||||
// — полный цикл: обмен code → токены → IAM → готовый объект.
|
||||
// Вызывается ОДИН раз при логине.
|
||||
// СЕЙЧАС: не используется в боевом режиме.
|
||||
// Причина: нужен /v2/callback в redirect URIs production KC.
|
||||
// Пока сессия берётся из основного OIDC приложения.
|
||||
//
|
||||
// buildAuthUrl(config) → string
|
||||
// — URL для редиректа пользователя на Keycloak.
|
||||
//
|
||||
// exchangeCode(code, config) → { accessToken, idToken, ... }
|
||||
// — обмен authorization_code на токены (обратный вызов Keycloak).
|
||||
//
|
||||
// fetchIamUser(token, iamUrl) → { email, clientId, profiles, ... }
|
||||
// — запрос профиля пользователя у IAM.
|
||||
//
|
||||
// generateState() → string
|
||||
// — случайная строка для OAuth state (CSRF-защита).
|
||||
// БЕЗОПАСНОСТЬ:
|
||||
// generateState() — CSRF-защита (случайная строка в куке).
|
||||
// nonce — защита от replay-атак (в id_token).
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 1. Keycloak OIDC — получение токенов
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* buildAuthUrl — строит URL для редиректа пользователя на Keycloak.
|
||||
*
|
||||
* Это ПЕРВЫЙ шаг OIDC-потока:
|
||||
* 1. Генерируем state (через generateState)
|
||||
* 2. Сохраняем state в сессии (для проверки в callback)
|
||||
* 3. Редиректим пользователя на этот URL
|
||||
*
|
||||
* Keycloak показывает форму входа, после успешного входа редиректит
|
||||
* обратно на redirectUri с параметрами ?code=...&state=...
|
||||
*
|
||||
* @param {object} config
|
||||
* .baseUrl — URL Keycloak realm, например "https://keycloak.nubes.ru/realms/cloud"
|
||||
* .clientId — client_id приложения в Keycloak
|
||||
* .redirectUri — callback URL нашего приложения (должен совпадать с настройками Keycloak)
|
||||
* .state — случайная строка для CSRF-защиты (результат generateState())
|
||||
* @returns {string} — полный URL для редиректа
|
||||
*
|
||||
* Пример возврата:
|
||||
* "https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth
|
||||
* ?client_id=ipwhitelist&redirect_uri=https://app.ru/callback&response_type=code
|
||||
* &scope=openid+profile+email&state=a1b2c3..."
|
||||
*/
|
||||
// ── buildAuthUrl(config) → URL ─────────────────────────────────────────────
|
||||
// Формирует URL для редиректа на Keycloak /auth.
|
||||
// Параметры: client_id, redirect_uri, response_type=code, scope=openid, state, nonce.
|
||||
function buildAuthUrl(config) {
|
||||
const { baseUrl, clientId, redirectUri, state } = config;
|
||||
const authUrl = new URL(baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/auth');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('scope', 'openid profile email');
|
||||
authUrl.searchParams.set('state', state);
|
||||
return authUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* exchangeCode — обменивает authorization_code на токены.
|
||||
*
|
||||
* Это ВТОРОЙ шаг OIDC-потока:
|
||||
* Keycloak редиректит пользователя обратно с ?code=...
|
||||
* Мы отправляем этот code на token endpoint и получаем токены.
|
||||
*
|
||||
* Запрос: POST /protocol/openid-connect/token
|
||||
* Content-Type: application/x-www-form-urlencoded
|
||||
* grant_type=authorization_code&code=...&client_id=...&client_secret=...&redirect_uri=...
|
||||
*
|
||||
* @param {string} code — authorization_code из query-параметра callback-URL
|
||||
* @param {object} config
|
||||
* .baseUrl — URL Keycloak realm
|
||||
* .clientId — client_id приложения
|
||||
* .clientSecret — client_secret приложения (секретный!)
|
||||
* .redirectUri — тот же redirect_uri что и в buildAuthUrl
|
||||
* @returns {object} — ответ Keycloak:
|
||||
* .accessToken — JWT для доступа к API (передаём в IAM)
|
||||
* .idToken — JWT с информацией о пользователе (опционально)
|
||||
* .refreshToken — для обновления (не используется)
|
||||
* .expiresIn — срок действия access_token
|
||||
* @throws {Error} — если Keycloak вернул не 200 (неверный code, secret, redirect_uri)
|
||||
*/
|
||||
async function exchangeCode(code, config) {
|
||||
const { baseUrl, clientId, clientSecret, redirectUri } = config;
|
||||
const tokenUrl = baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/token';
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
const state = generateState();
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.kcClientId,
|
||||
redirect_uri: config.appUrl + '/v2/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return postForm(tokenUrl, body);
|
||||
return config.kcBaseUrl + '/protocol/openid-connect/auth?' + params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* generateState — генерирует случайную строку для OAuth state.
|
||||
*
|
||||
* Зачем: защита от CSRF в OAuth-потоке.
|
||||
* 1. Генерируем → сохраняем в сессию
|
||||
* 2. Передаём в buildAuthUrl
|
||||
* 3. Keycloak возвращает state в callback
|
||||
* 4. Сверяем: req.query.state === req.session.oidcState
|
||||
*
|
||||
* @returns {string} — 32 hex-символа (16 байт)
|
||||
*/
|
||||
// ── exchangeCode(code, config) → { access_token, id_token, refresh_token } ─
|
||||
// Обменивает authorization code на токены через KC /token.
|
||||
function exchangeCode(code, config) {
|
||||
// HTTP POST к KC token endpoint
|
||||
// Возвращает токены
|
||||
}
|
||||
|
||||
// ── fetchIamUser(token, iamUrl) → профиль ──────────────────────────────────
|
||||
// Запрашивает профиль пользователя из IAM API.
|
||||
// Вход: Bearer token, URL IAM.
|
||||
// Выход: { email, clientId, allClientIds, companyName, fio, profiles, ... }
|
||||
function fetchIamUser(token, iamUrl) {
|
||||
// HTTP GET к IAM API с Bearer token
|
||||
// Возвращает плоский объект пользователя
|
||||
}
|
||||
|
||||
// ── generateState() → случайная строка ─────────────────────────────────────
|
||||
// CSRF-защита: сервер генерирует state, кладёт в куку, сверяет при возврате.
|
||||
function generateState() {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 2. IAM API — профиль пользователя
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* fetchIamUser — запрашивает профиль пользователя у IAM.
|
||||
*
|
||||
* Это ТРЕТИЙ шаг аутентификации:
|
||||
* Имея access_token от Keycloak, запрашиваем /api/v1/auth/user
|
||||
* IAM возвращает: профили, роли, имперсонацию.
|
||||
*
|
||||
* Запрос: GET {iamUrl}/api/v1/auth/user
|
||||
* Authorization: Bearer {access_token}
|
||||
* Accept: application/json
|
||||
*
|
||||
* Ответ IAM (JSON):
|
||||
* userInfo: { email, clientID, companyId, company, isAdmin, fio }
|
||||
* profiles[]: [{ id, client_id, company_name, is_active_profile }]
|
||||
* impersonation: { is_impersonated, type, originalUserEmail, ... }
|
||||
*
|
||||
* Все поля плоские — не нужно лазить в userInfo.email, просто email.
|
||||
*
|
||||
* @param {string} token — access_token от Keycloak (из exchangeCode)
|
||||
* @param {string} iamUrl — IAM_API_URL, например "https://auth-api-dev.ngcloud.ru"
|
||||
* @returns {object} — плоский объект со всеми полями:
|
||||
* .email — email пользователя
|
||||
* .clientId — W-номер активной компании (WZ01112)
|
||||
* .companyId — внутренний ID IAM (не W-формат, почти не используется)
|
||||
* .companyName — название активной компании
|
||||
* .isAdmin — флаг администратора (из userInfo.isAdmin)
|
||||
* .fio — полное имя
|
||||
* .profiles — массив всех профилей (все компании пользователя)
|
||||
* .allClientIds — все W-номера (собираем из profiles[].client_id)
|
||||
* .activeProfileId — id активного профиля
|
||||
* .isImpersonated — активна ли имперсонация
|
||||
* .impersonationType — тип имперсонации
|
||||
* .originalUserEmail — кто реально (если имперсонация)
|
||||
* .originalUserFullName — ФИО реального
|
||||
* .originalUserCompany — компания реального
|
||||
* .impersonatedCompanyId — в чью компанию вошли
|
||||
* .raw — сырой JSON ответ IAM (для отладки)
|
||||
* @throws {Error} — если IAM недоступен, вернул не 200, или ответ не JSON
|
||||
*/
|
||||
async function fetchIamUser(token, iamUrl) {
|
||||
const url = new URL(iamUrl.replace(/\/$/, '') + '/api/v1/auth/user');
|
||||
|
||||
const data = await httpGet(url, token);
|
||||
|
||||
const raw = JSON.parse(data);
|
||||
const profiles = raw.profiles || [];
|
||||
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
|
||||
const ui = raw.userInfo || {};
|
||||
const imp = raw.impersonation || {};
|
||||
|
||||
return {
|
||||
// ── из userInfo ──
|
||||
email: ui.email || '',
|
||||
clientId: ui.clientID || activeProfile.client_id || '', // W-номер — активная компания
|
||||
companyId: ui.companyId || '', // внутр. ID IAM (не W-формат)
|
||||
companyName: ui.company || activeProfile.company_name || '',
|
||||
isAdmin: !!ui.isAdmin,
|
||||
fio: (ui.fio && ui.fio.fullName) || ui.fio || null,
|
||||
|
||||
// ── из profiles ──
|
||||
profiles, // все компании (массив)
|
||||
allClientIds: profiles.map(p => p.client_id).filter(Boolean), // все W-номера (плоский массив)
|
||||
activeProfileId: activeProfile.id || null,
|
||||
|
||||
// ── из impersonation ──
|
||||
isImpersonated: !!(imp.is_impersonated),
|
||||
impersonationType: imp.type || null,
|
||||
originalUserEmail: imp.originalUserEmail || '',
|
||||
originalUserFullName: imp.originalUserFullName || '',
|
||||
originalUserCompany: imp.originalUserCompany || '',
|
||||
impersonatedCompanyId: imp.impersonatedCompanyId || '',
|
||||
|
||||
// ── сырой ответ (для отладки) ──
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 3. Оркестратор — единая точка входа
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* login — полный цикл аутентификации.
|
||||
*
|
||||
* Выполняет последовательно:
|
||||
* 1. exchangeCode(code, oidcConfig) → accessToken
|
||||
* 2. fetchIamUser(accessToken, iamUrl) → профиль пользователя
|
||||
* 3. Собирает готовый sessionUser для записи в req.session.user
|
||||
*
|
||||
* После этого вызова:
|
||||
* — НЕ НУЖЕН Keycloak (токен получен)
|
||||
* — НЕ НУЖЕН IAM (профиль получен, switchProfile не нужен — компании переключаем в сессии)
|
||||
*
|
||||
* Вызывается ОДИН раз — в callback-роуте OIDC (/callback).
|
||||
*
|
||||
* @param {string} code — authorization_code из query-параметра callback-URL
|
||||
* @param {object} oidcConfig
|
||||
* .baseUrl — URL Keycloak realm
|
||||
* .clientId — client_id приложения
|
||||
* .clientSecret — client_secret приложения
|
||||
* .redirectUri — callback URL приложения
|
||||
* @param {string} iamUrl — IAM_API_URL
|
||||
* @returns {object}
|
||||
* .sessionUser — готовый объект для req.session.user:
|
||||
* { email, clientId, allClientIds, activeClientId, companyName,
|
||||
* isAdmin, fio, profiles, isImpersonated, originalUserEmail, ... }
|
||||
* .token — access_token (JWT для API-запросов)
|
||||
* .idToken — id_token (опционально)
|
||||
* @throws {Error} — на любом этапе (Keycloak не ответил, IAM недоступен, ...)
|
||||
*/
|
||||
async function login(code, oidcConfig, iamUrl) {
|
||||
// 1. Обмен code на токены Keycloak
|
||||
const tokenData = await exchangeCode(code, oidcConfig);
|
||||
const accessToken = tokenData.accessToken;
|
||||
if (!accessToken) throw new Error('No access_token in Keycloak response');
|
||||
|
||||
// 2. Профиль пользователя из IAM
|
||||
const iamData = await fetchIamUser(accessToken, iamUrl);
|
||||
|
||||
// 3. Собираем sessionUser — всё что нужно для сессии
|
||||
const sessionUser = {
|
||||
email: iamData.email,
|
||||
clientId: iamData.clientId, // W-номер активной компании
|
||||
allClientIds: iamData.allClientIds, // все W-номера пользователя
|
||||
activeClientId: iamData.clientId, // активная компания (можно менять в сессии)
|
||||
activeProfileId: iamData.activeProfileId,
|
||||
companyId: iamData.companyId,
|
||||
companyName: iamData.companyName,
|
||||
isAdmin: iamData.isAdmin,
|
||||
fio: iamData.fio,
|
||||
profiles: iamData.profiles, // все профили (для переключения компаний)
|
||||
|
||||
// имперсонация
|
||||
isImpersonated: iamData.isImpersonated,
|
||||
impersonationType: iamData.impersonationType,
|
||||
originalUserEmail: iamData.originalUserEmail,
|
||||
originalUserFullName: iamData.originalUserFullName,
|
||||
originalUserCompany: iamData.originalUserCompany,
|
||||
};
|
||||
|
||||
return {
|
||||
sessionUser,
|
||||
token: accessToken,
|
||||
idToken: tokenData.idToken || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 4. HTTP-хелперы (внутренние, не экспортируются)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* httpGet — GET-запрос с Bearer-токеном.
|
||||
*
|
||||
* Особенности:
|
||||
* — Только JSON-ответы (Accept: application/json)
|
||||
* — Таймаут 10 секунд
|
||||
* — Лимит ответа 100 KB (защита от переполнения памяти)
|
||||
* — Авто-выбор http/https по URL
|
||||
*
|
||||
* @param {URL} url — parsed URL (new URL(...))
|
||||
* @param {string} token — Bearer-токен
|
||||
* @returns {string} — тело ответа
|
||||
* @throws {Error} — HTTP не 200, таймаут, или ответ > 100 KB
|
||||
*/
|
||||
function httpGet(url, token) {
|
||||
const lib = url.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = lib.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => {
|
||||
data += c;
|
||||
if (data.length > 100_000) {
|
||||
req.destroy();
|
||||
reject(new Error('Response too large (>100KB)'));
|
||||
}
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||
}
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('HTTP request timeout (10s)'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* postForm — POST-запрос с form-urlencoded телом.
|
||||
*
|
||||
* Используется для Keycloak token endpoint.
|
||||
*
|
||||
* Особенности:
|
||||
* — Content-Type: application/x-www-form-urlencoded
|
||||
* — Accept: application/json (ожидаем JSON в ответе)
|
||||
* — Таймаут 10 секунд
|
||||
*
|
||||
* @param {string} urlStr — полный URL
|
||||
* @param {URLSearchParams} body — параметры формы
|
||||
* @returns {object} — распарсенный JSON-ответ
|
||||
* @throws {Error} — HTTP не 200, таймаут, или ответ не JSON
|
||||
*/
|
||||
function postForm(urlStr, body) {
|
||||
const url = new URL(urlStr);
|
||||
const lib = url.protocol === 'https:' ? https : http;
|
||||
const postData = body.toString();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = lib.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`POST ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||
}
|
||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('POST request timeout (10s)'));
|
||||
});
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Экспорт
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
module.exports = {
|
||||
// основной — один вызов на сессию
|
||||
login,
|
||||
|
||||
// атомарные (для гибкости / тестов)
|
||||
buildAuthUrl,
|
||||
exchangeCode,
|
||||
fetchIamUser,
|
||||
generateState,
|
||||
};
|
||||
module.exports = { buildAuthUrl, exchangeCode, fetchIamUser, generateState };
|
||||
|
||||
+16
-6
@@ -1,12 +1,22 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Конфигурация V2
|
||||
// V2 — конфигурация
|
||||
//
|
||||
// СЕЙЧАС: IAM URL для fetchIamUser() + версия.
|
||||
// ПОТОМ: добавить KC_BASE_URL, KC_CLIENT_ID, KC_CLIENT_SECRET для своего OIDC.
|
||||
// ЭТО: все настройки в одном месте.
|
||||
// ЗАЧЕМ: всё что может поменяться между средами — здесь, не размазано по коду.
|
||||
//
|
||||
// ПРИОРИТЕТ: process.env (V2_*) → хардкод-умолчания.
|
||||
// Умолчания — для тестовой среды (VM KC на italo.kube5s.ru:8080).
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
module.exports = {
|
||||
version: '0.5.93',
|
||||
iamUrl: process.env.V2_IAM_API_URL || 'https://auth-api.ngcloud.ru',
|
||||
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
|
||||
version: '0.5.94',
|
||||
|
||||
// ── IAM ──────────────────────────────────────────────────────────────────
|
||||
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
|
||||
|
||||
// ── OIDC (Keycloak) ──────────────────────────────────────────────────────
|
||||
kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://italo.kube5s.ru:8080/realms/ipwhitelist',
|
||||
kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist-app',
|
||||
kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '',
|
||||
appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru',
|
||||
};
|
||||
|
||||
+39
-8
@@ -1,23 +1,42 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — CRUD API-слой (чистые функции, без Express)
|
||||
// V2 — CRUD API-слой
|
||||
//
|
||||
// Принимает clientId (W-номер), сам резолвит companyId через getOrCreateCompany.
|
||||
// Фронтенд (user/) и тесты (test/) дергают эти функции.
|
||||
// ЭТО: чистые функции — API между фронтендами и БД.
|
||||
// НЕ: Express, HTTP, req/res, сессии.
|
||||
//
|
||||
// Сигнатуры:
|
||||
// list(clientId) → { entries, used, limit }
|
||||
// add(clientId, cidr, comment, email, impBy) → { entry, wasNormalized }
|
||||
// edit(entryId, clientId, cidr, comment, email, impBy) → { entry, wasNormalized }
|
||||
// remove(entryId, clientId, email, impBy) → void
|
||||
// ЗАЧЕМ:
|
||||
// 1. Фронтенды (user/, admin/, test/) не знают про SQL, транзакции, companyId.
|
||||
// 2. Валидация вызывается ОДИН раз здесь, не дублируется в каждом фронтенде.
|
||||
// 3. clientId (W-номер) → companyId (внутренний ID БД) резолвится здесь.
|
||||
// 4. Каждую функцию можно тестировать изолированно, без Express.
|
||||
//
|
||||
// КОНТРАКТ:
|
||||
// list(clientId) → { entries, used, limit }
|
||||
// add(clientId, cidr, ...) → { entry, wasNormalized }
|
||||
// edit(entryId, clientId, ...) → { entry, wasNormalized }
|
||||
// remove(entryId, clientId, ...) → void
|
||||
//
|
||||
// Вход: clientId — W-номер компании (строка вида "WZ10001").
|
||||
// email — почта юзера (для аудита).
|
||||
// impBy — почта админа при имперсонации (null если нет).
|
||||
//
|
||||
// Ошибки: throw — валидация, лимит, дубликат, пересечение.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const q = require('../db/queries');
|
||||
const { validate } = require('../validators');
|
||||
|
||||
// ── resolve(clientId) → company (внутренний хелпер) ──────────────────────────
|
||||
// Атомарный upsert: если компании с таким W-номером нет — создаст.
|
||||
// Возвращает { id, client_id, name, custom_limit }.
|
||||
// Вызывается из list/add/edit/remove перед каждой операцией.
|
||||
async function resolve(clientId) {
|
||||
return q.getOrCreateCompany(clientId, clientId);
|
||||
}
|
||||
|
||||
// ── list(clientId, includeDeleted?) → { entries, used, limit } ──────────────
|
||||
// Всегда возвращает лимит и счётчик активных записей.
|
||||
// used — только неудалённые (deleted_at IS NULL).
|
||||
async function list(clientId, includeDeleted = false) {
|
||||
const co = await resolve(clientId);
|
||||
const entries = await q.listEntries(co.id, includeDeleted);
|
||||
@@ -26,18 +45,30 @@ async function list(clientId, includeDeleted = false) {
|
||||
return { entries, used, limit };
|
||||
}
|
||||
|
||||
// ── add(clientId, cidr, comment, email, impBy) → { entry, wasNormalized } ───
|
||||
// ПОРЯДОК:
|
||||
// 1. validate(rawCidr) — проверка ТЗ (маска, диапазоны, нормализация).
|
||||
// 2. resolve(clientId) — получить/создать компанию.
|
||||
// 3. db.createEntry() — транзакция: лимит, дубликаты, INSERT + аудит.
|
||||
// Если validate() бросает — до БД не доходим.
|
||||
async function add(clientId, cidr, comment, email, impBy) {
|
||||
const { cidr: validated, wasNormalized } = validate(cidr);
|
||||
const co = await resolve(clientId);
|
||||
return q.createEntry(co.id, validated, comment, email, impBy, wasNormalized);
|
||||
}
|
||||
|
||||
// ── edit(entryId, clientId, cidr, comment, email, impBy) → { entry, wasNormalized }
|
||||
// Аналогично add, но для существующей записи.
|
||||
// entryId — id из v2_entries (число).
|
||||
async function edit(entryId, clientId, cidr, comment, email, impBy) {
|
||||
const { cidr: validated, wasNormalized } = validate(cidr);
|
||||
const co = await resolve(clientId);
|
||||
return q.updateEntry(entryId, co.id, validated, comment, email, impBy, wasNormalized);
|
||||
}
|
||||
|
||||
// ── remove(entryId, clientId, email, impBy) → void ──────────────────────────
|
||||
// Soft delete: ставит deleted_at, записывает аудит.
|
||||
// Запись остаётся в БД, но не показывается в list().
|
||||
async function remove(entryId, clientId, email, impBy) {
|
||||
const co = await resolve(clientId);
|
||||
return q.deleteEntry(entryId, co.id, email, impBy);
|
||||
|
||||
+7
-1
@@ -1,5 +1,11 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — подключение к PostgreSQL
|
||||
// V2 — PostgreSQL connection pool
|
||||
//
|
||||
// ЭТО: pg.Pool — единственное подключение к БД.
|
||||
// ЗАЧЕМ: все модули (crud, queries, schema) используют один pool.
|
||||
//
|
||||
// ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ (из основного .env):
|
||||
// DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
+19
-2
@@ -1,6 +1,23 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — SQL-запросы (единственное место работы с БД)
|
||||
// Все функции работают с таблицами v2_companies, v2_entries, v2_audit
|
||||
// V2 — SQL-запросы
|
||||
//
|
||||
// ЭТО: единственное место где выполняется SQL.
|
||||
// НЕ: валидация (в crud/), бизнес-логика (в crud/).
|
||||
//
|
||||
// ЗАЧЕМ:
|
||||
// 1. Все SQL-запросы в одном файле — легко аудировать.
|
||||
// 2. Транзакции (BEGIN/COMMIT/ROLLBACK) только здесь.
|
||||
// 3. Фронтенды не видят SQL — только через crud API.
|
||||
//
|
||||
// ЧТО ПРИНИМАЕТ:
|
||||
// companyId — внутренний id из v2_companies (число).
|
||||
// cidr — УЖЕ проверенный CIDR (валидация в crud/).
|
||||
// email, impBy — для аудита.
|
||||
//
|
||||
// overlaps() — проверка пересечений с существующими записями (только здесь,
|
||||
// потому что требует данных из БД).
|
||||
//
|
||||
// ТАБЛИЦЫ: v2_companies, v2_entries, v2_audit (префикс v2_ — изоляция).
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const { pool } = require('./index');
|
||||
|
||||
+27
-18
@@ -1,25 +1,39 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — автосоздание таблиц при старте
|
||||
// Вызывается ОДИН раз: ensureSchema(pool)
|
||||
// V2 — инициализация схемы БД
|
||||
//
|
||||
// ЭТО: CREATE TABLE IF NOT EXISTS — безопасный повторный запуск.
|
||||
// ЗАЧЕМ:
|
||||
// 1. Приложение само создаёт таблицы при старте — не нужны миграции.
|
||||
// 2. v2_ префикс изолирует тестовые таблицы от основных.
|
||||
// 3. Безопасно для production: IF NOT EXISTS.
|
||||
//
|
||||
// ТАБЛИЦЫ:
|
||||
// v2_companies — client_id (W-номер), name, custom_limit
|
||||
// v2_entries — CIDR, комментарий, кто создал/изменил/удалил
|
||||
// v2_audit — все действия: CREATE/UPDATE/DELETE, с impersonated_by
|
||||
//
|
||||
// ИНДЕКСЫ: на company_id для быстрых SELECT + на unique(client_id).
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function ensureSchema(pool) {
|
||||
await pool.query(`
|
||||
-- Компании: W-номер уникален, upsert через ON CONFLICT
|
||||
CREATE TABLE IF NOT EXISTS v2_companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
client_id VARCHAR(64) UNIQUE NOT NULL,
|
||||
name VARCHAR(255),
|
||||
custom_limit INTEGER DEFAULT NULL CHECK (custom_limit IS NULL OR custom_limit >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
id SERIAL PRIMARY KEY,
|
||||
client_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
name VARCHAR(255),
|
||||
custom_limit INTEGER DEFAULT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Записи: CIDR + метаданные, soft delete
|
||||
CREATE TABLE IF NOT EXISTS v2_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id INTEGER NOT NULL REFERENCES v2_companies(id) ON DELETE RESTRICT,
|
||||
value_cidr VARCHAR(18) NOT NULL CHECK (value_cidr LIKE '%/%'),
|
||||
comment VARCHAR(255),
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
value_cidr VARCHAR(64) NOT NULL,
|
||||
comment TEXT,
|
||||
created_by VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by VARCHAR(255),
|
||||
updated_at TIMESTAMPTZ,
|
||||
@@ -27,12 +41,7 @@ async function ensureSchema(pool) {
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_v2_entries_active_cidr
|
||||
ON v2_entries(company_id, value_cidr) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_v2_entries_active
|
||||
ON v2_entries(company_id, created_at DESC) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Аудит: каждое действие с возможностью отследить имперсонацию
|
||||
CREATE TABLE IF NOT EXISTS v2_audit (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
|
||||
+39
-18
@@ -1,29 +1,50 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — роутер по роли
|
||||
// Определяет контекст (admin/user/impersonation) и направляет куда нужно.
|
||||
// V2 — resolveContext middleware
|
||||
//
|
||||
// ЭТО: Express middleware — извлекает контекст из сессии.
|
||||
// НЕ: БД, бизнес-логика.
|
||||
//
|
||||
// ЗАЧЕМ:
|
||||
// 1. Единственное место где сессия превращается в req.* поля.
|
||||
// 2. user/ и admin/ не парсят сессию сами — получают готовые req.email, req.clientId.
|
||||
// 3. Поддержка имперсонации (админ действует от имени компании).
|
||||
//
|
||||
// ПОРЯДОК:
|
||||
// 1. Нет сессии → 302 /v2/login
|
||||
// 2. Обычный юзер → req.email = session.user.email, req.clientId = activeClientId
|
||||
// 3. Админ → req.isAdmin = true (если adminMode в сессии)
|
||||
// 4. Имперсонация → req.email = originalUserEmail, req.impersonatedBy = админ
|
||||
//
|
||||
// ВЫХОД (поля на req):
|
||||
// req.email — почта (при имперсонации — originalUserEmail)
|
||||
// req.clientId — W-номер текущей компании
|
||||
// req.companyName — название компании
|
||||
// req.isAdmin — флаг админа
|
||||
// req.isImpersonated — флаг имперсонации
|
||||
// req.impersonatedBy — почта админа (null если нет имперсонации)
|
||||
// req.allClientIds — все W-номера юзера
|
||||
// req.profiles — профили из IAM
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* middleware — извлекает контекст из сессии и кладёт в req.*
|
||||
* Вызывается перед всеми маршрутами v2.
|
||||
*/
|
||||
function resolveContext(req, res, next) {
|
||||
const u = req.session && req.session.user;
|
||||
|
||||
// Нет сессии — нет доступа
|
||||
if (!u) return res.redirect('/v2/login');
|
||||
|
||||
const isAdmin = !!(u.isAdmin && req.session.adminMode);
|
||||
const isImpersonated = !!u.isImpersonated;
|
||||
// Имперсонация: админ действует от имени компании
|
||||
// u.originalUserEmail — реальный админ
|
||||
// u.impersonatedCompanyId — W-номер компании
|
||||
const isImpersonated = !!(u.originalUserEmail && u.impersonatedCompanyId);
|
||||
|
||||
// Контекст для CRUD
|
||||
req.email = isImpersonated ? (u.originalUserEmail || u.email) : u.email;
|
||||
req.clientId = isImpersonated ? (u.impersonatedCompanyId || u.activeClientId || u.clientId) : (u.activeClientId || u.clientId);
|
||||
req.isAdmin = isAdmin;
|
||||
req.isImpersonated = isImpersonated;
|
||||
req.impersonatedBy = isImpersonated ? u.email : null; // кто реально (для аудита)
|
||||
req.user = u;
|
||||
req.allClientIds = u.allClientIds || [];
|
||||
req.profiles = u.profiles || [];
|
||||
req.companyName = u.companyName || u.clientId;
|
||||
req.email = isImpersonated ? u.originalUserEmail : (u.email || '');
|
||||
req.clientId = isImpersonated ? u.impersonatedCompanyId : (u.activeClientId || u.clientId || '');
|
||||
req.companyName = u.companyName || req.clientId;
|
||||
req.isAdmin = !!(u.isAdmin && u.adminMode);
|
||||
req.isImpersonated = isImpersonated;
|
||||
req.impersonatedBy = isImpersonated ? u.email : null;
|
||||
req.allClientIds = u.allClientIds || [req.clientId];
|
||||
req.profiles = u.profiles || [];
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
+49
-19
@@ -1,14 +1,39 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — тестовый эндпоинт для CRUD
|
||||
// Принимает ?action=...&user=... → JSON-ответ
|
||||
// V2 — Тестовый слой
|
||||
//
|
||||
// ЭТО: Express-роутер для тестирования ВСЕХ слоёв через curl.
|
||||
// НЕ: production-код (отключается флагом ENABLE_TEST_API=false).
|
||||
//
|
||||
// ЗАЧЕМ:
|
||||
// 1. Тестировать слои (crud, db, validators) на реальной БД без KC-сессии.
|
||||
// 2. Эмулировать поведение юзера (userFlow).
|
||||
// 3. Нагрузочное тестирование (chaos — 40 параллельных операций).
|
||||
// 4. Очистка тестовых данных (cleanup).
|
||||
//
|
||||
// МОК-ЮЗЕРЫ: u1, u2, imp — эмулируют реальных пользователей IAM.
|
||||
// u1 — обычный юзер, одна компания (WZ10001)
|
||||
// u2 — юзер с двумя компаниями (WZ20001, WZ20002)
|
||||
// imp — админ под имперсонацией (WZ01112 → WZ03709)
|
||||
//
|
||||
// ACTIONS (GET ?action=...):
|
||||
// add/edit/delete/list — CRUD через crud/
|
||||
// audit — аудит через db/queries
|
||||
// limit — лимит компании
|
||||
// switch — переключение компании
|
||||
// cleanup — удаление тестовых данных
|
||||
// userFlow — полная эмуляция юзера с мок-сессией
|
||||
//
|
||||
// CHAOS (GET /chaos):
|
||||
// 4 компании × 10 записей = 40 параллельных Promise.all.
|
||||
// Проверяет: дубликаты, лимиты, аудит.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const express = require('express');
|
||||
const crud = require('../crud');
|
||||
const q = require('../db/queries');
|
||||
const { pool } = require('../db');
|
||||
const crud = require('../crud'); // API-слой БД
|
||||
const q = require('../db/queries'); // прямой доступ для аудита/лимитов
|
||||
const { pool } = require('../db'); // прямой доступ для cleanup
|
||||
|
||||
// Мок-юзеры
|
||||
// Мок-юзеры — эмулируют данные из IAM после KC-входа
|
||||
const USERS = {
|
||||
u1: { email:'u1@t.ru', clientId:'WZ10001', activeClientId:'WZ10001', allClientIds:['WZ10001'], companyName:'Одна', isAdmin:false, profiles:[{client_id:'WZ10001',company_name:'Одна'}] },
|
||||
u2: { email:'u2@t.ru', clientId:'WZ20001', activeClientId:'WZ20002', allClientIds:['WZ20001','WZ20002'], companyName:'Вторая', isAdmin:false, profiles:[{client_id:'WZ20001',company_name:'Первая'},{client_id:'WZ20002',company_name:'Вторая'}] },
|
||||
@@ -18,51 +43,53 @@ const USERS = {
|
||||
function createTestRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// ── GET / — главный тестовый эндпоинт (?action=...) ─────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const action = req.query.action;
|
||||
const user = USERS[req.query.user || 'u1'];
|
||||
if (!user) return res.json({ ok: false, error: 'unknown user' });
|
||||
|
||||
// Контекст из мок-юзера (аналог resolveContext)
|
||||
const email = user.isImpersonated ? user.originalUserEmail : user.email;
|
||||
const clientId = user.isImpersonated ? user.impersonatedCompanyId : user.activeClientId;
|
||||
const company = await q.getOrCreateCompany(clientId, user.companyName || clientId);
|
||||
const impBy = user.isImpersonated ? user.email : null;
|
||||
|
||||
switch (action) {
|
||||
// ── LIST ──
|
||||
// ── LIST: список записей компании ─────────────────────────
|
||||
case 'list': {
|
||||
const { entries, used, limit } = await crud.list(clientId, req.query.deleted === '1');
|
||||
return res.json({ ok: true, entries, count: entries.length, used, limit }); }
|
||||
|
||||
// ── ADD ──
|
||||
// ── ADD: добавить CIDR (crud.add → validate → db) ────────
|
||||
case 'add': {
|
||||
const r = await crud.add(clientId, req.query.cidr || '', req.query.comment || '', email, impBy);
|
||||
return res.json({ ok: true, entry: r.entry, wasNormalized: r.wasNormalized });
|
||||
}
|
||||
|
||||
// ── EDIT ──
|
||||
// ── EDIT: изменить существующую запись ────────────────────
|
||||
case 'edit': {
|
||||
const r = await crud.edit(parseInt(req.query.id), clientId, req.query.cidr || '', req.query.comment || '', email, impBy);
|
||||
return res.json({ ok: true, entry: r.entry, wasNormalized: r.wasNormalized });
|
||||
}
|
||||
|
||||
// ── DELETE ──
|
||||
// ── DELETE: soft delete ───────────────────────────────────
|
||||
case 'delete':
|
||||
await crud.remove(parseInt(req.query.id), clientId, email, impBy);
|
||||
return res.json({ ok: true });
|
||||
|
||||
// ── AUDIT ──
|
||||
// ── AUDIT: журнал действий компании ──────────────────────
|
||||
case 'audit': {
|
||||
const audit = await q.getAudit(company.id);
|
||||
return res.json({ ok: true, audit, count: audit.length });
|
||||
}
|
||||
|
||||
// ── LIMIT ──
|
||||
// ── LIMIT: текущий лимит компании ────────────────────────
|
||||
case 'limit':
|
||||
return res.json({ ok: true, limit: await q.getLimit(company) });
|
||||
|
||||
// ── SWITCH ──
|
||||
// ── SWITCH: переключение активной компании ───────────────
|
||||
case 'switch': {
|
||||
const to = req.query.to;
|
||||
if (!to) return res.json({ ok: false, error: 'no to parameter' });
|
||||
@@ -73,7 +100,7 @@ function createTestRouter() {
|
||||
return res.json({ ok: true, switchedTo: to });
|
||||
}
|
||||
|
||||
// ── CLEANUP ──
|
||||
// ── CLEANUP: удалить все данные тестовых компаний ───────
|
||||
case 'cleanup': {
|
||||
const cids = (req.query.cids || '').split(',');
|
||||
for (const c of cids) {
|
||||
@@ -84,7 +111,10 @@ function createTestRouter() {
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
// ── USERFLOW — полная эмуляция юзера через все слои ──
|
||||
// ── USERFLOW: полная эмуляция юзера через все слои ──────
|
||||
// Отличие от add/edit/delete: здесь выставляется мок-сессия
|
||||
// как после KC+IAM+resolveContext, и вызывается crud.
|
||||
// Показывает слой 'user→crud→db' в ответе.
|
||||
case 'userFlow': {
|
||||
const sub = req.query.sub; // add | edit | delete | list
|
||||
const cidr = req.query.cidr || '';
|
||||
@@ -138,10 +168,10 @@ function createTestRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /chaos — параллельный хаос-тест внутри приложения ────────────
|
||||
// ── GET /chaos — параллельный хаос-тест (40 операций) ──────────────
|
||||
router.get('/chaos', async (req, res) => {
|
||||
const cids = ['WZ10001','WZ20002','WZ30001','WZ01112'];
|
||||
// Очистка
|
||||
// Очистка перед тестом
|
||||
for (const c of cids) {
|
||||
await pool.query('DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{});
|
||||
await pool.query('DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{});
|
||||
@@ -169,7 +199,7 @@ function createTestRouter() {
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
|
||||
// Проверка ВСЕХ компаний
|
||||
// Проверка ВСЕХ компаний после теста
|
||||
const checks = [];
|
||||
let totalEntries = 0, totalAudit = 0, anyDupes = false, anyOverLimit = false;
|
||||
for (const clientId of cids) {
|
||||
@@ -184,7 +214,7 @@ function createTestRouter() {
|
||||
checks.push({ clientId, entries: entries.length, audit: audit.length, dupes, overLimit: entries.length > limit, limit });
|
||||
}
|
||||
|
||||
// Очистка
|
||||
// Очистка после теста
|
||||
for (const c of cids) {
|
||||
await pool.query('DELETE FROM v2_entries WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{});
|
||||
await pool.query('DELETE FROM v2_audit WHERE company_id IN (SELECT id FROM v2_companies WHERE client_id=$1)',[c]).catch(()=>{});
|
||||
|
||||
+34
-12
@@ -1,21 +1,41 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — пользовательский CRUD (фронтенд, только Express)
|
||||
// V2 — Фронтенд обычного пользователя
|
||||
//
|
||||
// Вход: req.clientId, req.email, req.impersonatedBy из resolveContext.
|
||||
// Всегда показывает выбор компании (даже если одна).
|
||||
// НЕ работает с БД напрямую — только через crud API-слой.
|
||||
// ЭТО: Express-роутер, который рендерит HTML и принимает POST-формы.
|
||||
// НЕ: работа с БД (только через crud/), валидация (в crud/).
|
||||
//
|
||||
// ЗАЧЕМ:
|
||||
// 1. Единственное место где генерится HTML для юзера.
|
||||
// 2. Все действия юзера (add/edit/delete) — через POST-формы.
|
||||
// 3. Выбор компании — всегда выпадающий список (даже если одна компания).
|
||||
// 4. Сообщения об ошибках/успехе — через query-параметры (?msg=, ?error=).
|
||||
//
|
||||
// КОНТЕКСТ (из resolveContext middleware):
|
||||
// req.clientId — W-номер текущей компании
|
||||
// req.email — почта юзера
|
||||
// req.impersonatedBy — null или почта админа
|
||||
// req.profiles — список компаний для выпадающего списка
|
||||
//
|
||||
// МАРШРУТЫ:
|
||||
// GET / — список записей + форма добавления
|
||||
// POST /add — добавить CIDR (→ crud.add → 302 с ?msg=)
|
||||
// POST /edit/:id — изменить запись
|
||||
// POST /delete/:id — удалить запись (soft delete)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const express = require('express');
|
||||
const crud = require('../crud');
|
||||
const crud = require('../crud'); // ← единственная зависимость от данных
|
||||
|
||||
function createUserRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// ── GET / — главная: список записей + форма ────────────────────────────
|
||||
// ── GET / — главная страница ──────────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const clId = req.clientId;
|
||||
|
||||
// Список компаний для выпадающего списка
|
||||
// Если profiles пуст — создаём fallback из clientId (одна компания)
|
||||
const allCompanies = req.profiles.length > 0
|
||||
? req.profiles
|
||||
: [{ client_id: clId, company_name: req.companyName || clId }];
|
||||
@@ -23,7 +43,7 @@ function createUserRouter() {
|
||||
const includeDeleted = req.query.deleted === '1';
|
||||
const { entries, used, limit } = await crud.list(clId, includeDeleted);
|
||||
|
||||
// Переключение компании
|
||||
// Переключение компании: запоминаем в сессии и редиректим
|
||||
if (req.query.switchTo && allCompanies.find(c => c.client_id === req.query.switchTo)) {
|
||||
req.session.user.activeClientId = req.query.switchTo;
|
||||
return res.redirect('/v2/app');
|
||||
@@ -35,18 +55,19 @@ function createUserRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /add ───────────────────────────────────────────────────────────
|
||||
// ── POST /add — добавить запись ───────────────────────────────────────
|
||||
router.post('/add', async (req, res) => {
|
||||
try {
|
||||
const result = await crud.add(req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
|
||||
const msg = result.wasNormalized ? 'Добавлено (адрес нормализован)' : 'Добавлено';
|
||||
res.redirect('/v2/app?msg=' + encodeURIComponent(msg));
|
||||
} catch (e) {
|
||||
// Ошибка валидации/лимита/дубликата — показываем юзеру
|
||||
res.redirect('/v2/app?error=' + encodeURIComponent(e.message));
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /edit/:id ──────────────────────────────────────────────────────
|
||||
// ── POST /edit/:id — изменить запись ──────────────────────────────────
|
||||
router.post('/edit/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await crud.edit(parseInt(req.params.id), req.clientId, req.body.cidr || '', req.body.comment || '', req.email, req.impersonatedBy);
|
||||
@@ -57,7 +78,7 @@ function createUserRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /delete/:id ────────────────────────────────────────────────────
|
||||
// ── POST /delete/:id — удалить запись (soft delete) ───────────────────
|
||||
router.post('/delete/:id', async (req, res) => {
|
||||
try {
|
||||
await crud.remove(parseInt(req.params.id), req.clientId, req.email, req.impersonatedBy);
|
||||
@@ -70,8 +91,9 @@ function createUserRouter() {
|
||||
return router;
|
||||
}
|
||||
|
||||
// ── HTML-рендеринг (временный, будет заменён на EJS) ─────────────────────
|
||||
|
||||
// ── renderPage() → HTML ─────────────────────────────────────────────────────
|
||||
// Временный inline HTML (будет заменён на EJS-шаблоны).
|
||||
// Данные: entries, limit, used, allCompanies, clId, user, includeDeleted.
|
||||
function renderPage({ entries, limit, used, allCompanies, clId, user, includeDeleted }) {
|
||||
const companyOptions = allCompanies.map(c =>
|
||||
`<option value="${c.client_id}" ${c.client_id === clId ? 'selected' : ''}>${c.company_name || c.client_id} (${c.client_id})</option>`
|
||||
|
||||
@@ -1,32 +1,67 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// V2 — валидация IPv4/CIDR
|
||||
// Чистые функции, только net (встроенный Node.js)
|
||||
// V2 — валидация CIDR по ТЗ
|
||||
//
|
||||
// ЭТО: чистые функции. НЕ: БД, Express, сеть.
|
||||
//
|
||||
// ЗАЧЕМ:
|
||||
// 1. Единственное место где определены правила ТЗ (диапазоны, маски).
|
||||
// 2. Вызывается в crud/ перед каждым добавлением/изменением.
|
||||
// 3. Если ТЗ поменяется — править только здесь.
|
||||
//
|
||||
// ПРОВЕРКИ (по порядку):
|
||||
// 1. Пустое значение
|
||||
// 2. IPv6 (не поддерживается)
|
||||
// 3. Двойной слеш (некорректный формат)
|
||||
// 4. Маска /22–/32 (ТЗ: нельзя шире /22)
|
||||
// 5. Валидный IPv4-адрес
|
||||
// 6. Нормализация хостовой части (13.0.0.5/24 → 13.0.0.0/24)
|
||||
// 7. Пересечение с BLOCKED_RANGES (14 диапазонов из Приложения А ТЗ)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const net = require('net');
|
||||
|
||||
// ── BLOCKED_RANGES — Приложение А ТЗ (14 диапазонов) ────────────────────────
|
||||
// Это ЕДИНСТВЕННОЕ место где хранятся запрещённые диапазоны.
|
||||
// При изменении ТЗ — править ТОЛЬКО этот массив.
|
||||
const BLOCKED_RANGES = [
|
||||
'10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16',
|
||||
'100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16',
|
||||
'192.0.0.0/24', '192.0.2.0/24', '198.51.100.0/24',
|
||||
'203.0.113.0/24', '198.18.0.0/15', '224.0.0.0/4',
|
||||
'240.0.0.0/4', '0.0.0.0/8', '255.255.255.255/32',
|
||||
'10.0.0.0/8', // Private RFC1918
|
||||
'172.16.0.0/12', // Private RFC1918
|
||||
'192.168.0.0/16', // Private RFC1918
|
||||
'100.64.0.0/10', // CGNAT RFC6598
|
||||
'127.0.0.0/8', // Loopback
|
||||
'169.254.0.0/16', // Link-local
|
||||
'192.0.0.0/24', // IANA special
|
||||
'192.0.2.0/24', // TEST-NET-1
|
||||
'198.51.100.0/24', // TEST-NET-2
|
||||
'203.0.113.0/24', // TEST-NET-3
|
||||
'198.18.0.0/15', // Benchmarking
|
||||
'224.0.0.0/4', // Multicast
|
||||
'240.0.0.0/4', // Reserved Class E
|
||||
'0.0.0.0/8', // "This" network
|
||||
'255.255.255.255/32',// Broadcast
|
||||
];
|
||||
|
||||
// ── validate(input) → { cidr, wasNormalized } ───────────────────────────────
|
||||
// ПОЛНАЯ проверка по ТЗ.
|
||||
// wasNormalized=true — если хостовая часть была обнулена (13.0.0.5/24 → 13.0.0.0/24).
|
||||
// Бросает Error с русским текстом — пользователь видит в UI.
|
||||
function validate(input) {
|
||||
const raw = (input || '').trim();
|
||||
if (!raw) throw new Error('Пустое значение');
|
||||
if (raw.includes(':')) throw new Error('IPv6 не поддерживается');
|
||||
if ((raw.match(/\//g) || []).length > 1) throw new Error('Некорректный формат');
|
||||
|
||||
// Если маска не указана — считаем /32 (одиночный хост)
|
||||
let cidr = raw.includes('/') ? raw : raw + '/32';
|
||||
const [addr, maskStr] = cidr.split('/');
|
||||
|
||||
// Маска: только цифры, одна или две
|
||||
if (!/^\d{1,2}$/.test(maskStr)) throw new Error('Некорректная маска');
|
||||
const mask = parseInt(maskStr, 10);
|
||||
if (mask < 22 || mask > 32) throw new Error('Маска должна быть от /22 до /32');
|
||||
if (!net.isIPv4(addr)) throw new Error('Некорректный IPv4 адрес');
|
||||
|
||||
// Нормализация: обнуляем хостовую часть битовой маской
|
||||
const ipNum = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
|
||||
const netMask = ~((1 << (32 - mask)) - 1) >>> 0;
|
||||
const network = (ipNum & netMask) >>> 0;
|
||||
@@ -36,6 +71,7 @@ function validate(input) {
|
||||
].join('.');
|
||||
const normalized = networkAddr + '/' + mask;
|
||||
|
||||
// Проверка против ВСЕХ запрещённых диапазонов
|
||||
for (const blocked of BLOCKED_RANGES)
|
||||
if (overlaps(normalized, blocked))
|
||||
throw new Error(`Диапазон ${normalized} пересекается с запрещённым (${blocked})`);
|
||||
@@ -43,11 +79,16 @@ function validate(input) {
|
||||
return { cidr: normalized, wasNormalized: addr !== networkAddr };
|
||||
}
|
||||
|
||||
// ── overlaps(cidr1, cidr2) → boolean ───────────────────────────────────────
|
||||
// Проверяет пересечение двух CIDR-диапазонов.
|
||||
// Используется: validate() для BLOCKED_RANGES, db/queries для проверки дубликатов.
|
||||
function overlaps(cidr1, cidr2) {
|
||||
const a = cidrToRange(cidr1), b = cidrToRange(cidr2);
|
||||
return a.start <= b.end && b.start <= a.end;
|
||||
}
|
||||
|
||||
// ── cidrToRange(cidr) → { start, end } ──────────────────────────────────────
|
||||
// CIDR → числовой диапазон (start/end — uint32 IP-адреса).
|
||||
function cidrToRange(cidr) {
|
||||
const [addr, maskStr] = cidr.split('/');
|
||||
const ip = addr.split('.').reduce((acc, o) => (acc << 8) + parseInt(o, 10), 0) >>> 0;
|
||||
@@ -55,6 +96,9 @@ function cidrToRange(cidr) {
|
||||
return { start: ip, end: (ip | ((1 << (32 - mask)) - 1)) >>> 0 };
|
||||
}
|
||||
|
||||
// ── aggregateCIDRs(cidrs) → string[] ───────────────────────────────────────
|
||||
// Схлопывание списка CIDR в минимальный набор (для экспорта).
|
||||
// Пример: 10.0.0.0/24 + 10.0.1.0/24 → 10.0.0.0/23
|
||||
function aggregateCIDRs(cidrs) {
|
||||
const ranges = cidrs.map(c => cidrToRange(c)).sort((a, b) => a.start - b.start);
|
||||
const merged = [];
|
||||
|
||||
Reference in New Issue
Block a user