v2: подробные комментарии во всех модулях — что, зачем, контракты

This commit is contained in:
2026-06-13 07:37:07 +04:00
parent 1af304b2a8
commit 616b4ead09
14 changed files with 592 additions and 518 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ipwhitelist",
"version": "0.5.93",
"version": "0.5.94",
"description": "IP WhiteList microservice for cloud provider",
"main": "server.js",
"scripts": {
+183
View File
@@ -369,3 +369,186 @@ remove(entryId, clientId, email, impBy) → void
1. **Тестирование**: crud тестируется без Express, user тестируется с моком crud
2. **Переиспользование**: admin, test, export — все через crud
3. **Независимость**: фронтенд не знает про deviceId, БД-схему, транзакции
---
## Текущее состояние (0.5.93) — полная архитектура
### Слои приложения
```
┌─────────────────────────────────────────────────────┐
│ Express HTTP │
├────────────┬────────────────┬───────────────────────┤
│ user/ │ admin/ │ test/ │
│ (юзер) │ (админ) │ (тестовый слой) │
│ HTML+POST │ HTML+POST │ JSON, no session │
├────────────┴────────────────┴───────────────────────┤
│ router/ │
│ resolveContext middleware │
│ сессия → req.clientId, email, isAdmin │
├─────────────────────────────────────────────────────┤
│ crud/ │
│ API БД (чистые функции) │
│ list(clientId) / add / edit / remove │
│ validate() вызывается здесь │
├─────────────────────────────────────────────────────┤
│ db/ │
│ queries.js (SQL) + schema.js (DDL) │
│ чистый SQL: createEntry, updateEntry, ... │
├─────────────────────────────────────────────────────┤
│ validators/ │
│ validate(cidr), overlaps(), BLOCKED_RANGES │
├─────────────────────────────────────────────────────┤
│ auth/ + config/ │
│ OIDC: login, exchangeCode, fetchIamUser │
└─────────────────────────────────────────────────────┘
```
### Слои — внутренние API (не HTTP)
Каждый слой — модуль Node.js с контрактом:
| Слой | Экспорт | Вход | Выход |
|------|---------|------|-------|
| **validators/** | `validate(raw)` | строка CIDR | `{ cidr, wasNormalized }` или throw |
| **crud/** | `add(clientId, cidr, ...)` | W-номер + параметры | `{ entry, wasNormalized }` или throw |
| **crud/** | `list(clientId)` | W-номер | `{ entries, used, limit }` |
| **crud/** | `edit(entryId, clientId, cidr, ...)` | ID + W-номер | `{ entry, wasNormalized }` |
| **crud/** | `remove(entryId, clientId, ...)` | ID + W-номер | void |
| **db/** | `createEntry(companyId, cidr, ...)` | внутренний ID + чистый CIDR | `{ entry }` |
| **db/** | `getAllCompanies()` | — | массив компаний |
| **db/** | `getAudit(companyId)` | companyId или null | массив записей аудита |
| **db/** | `setLimit(companyId, limit)` | ID + число | void |
### Структура файлов
```
v2/
├── server.js # createV2Router() — монтаж всех роутеров
├── history/
│ └── 2026-06-12.md # этот файл
└── src/
├── auth/index.js # OIDC: login, exchangeCode, fetchIamUser, buildAuthUrl
├── config/index.js # V2_* env, version, умолчания
├── router/index.js # resolveContext — сессия → req.*
├── validators/
│ └── index.js # validate, overlaps, BLOCKED_RANGES (14 диапазонов ТЗ)
├── crud/
│ └── index.js # list, add, edit, remove — API БД, вызывает validate()
├── db/
│ ├── index.js # pg pool
│ ├── queries.js # SQL: CRUD + admin (getAllCompanies, setLimit, getAudit)
│ └── schema.js # ensureSchema — автосоздание v2_companies, v2_entries, v2_audit
├── user/
│ └── index.js # createUserRouter — фронт юзера: HTML + POST /add /edit /delete
├── admin/
│ └── index.js # createAdminRouter — дашборд, аудит, лимиты, записи
└── test/
├── index.js # createTestRouter — тестовый API + chaos + userFlow
└── test.sh # 54 curl-теста
```
### Маршруты /v2
| Маршрут | Слой | Авторизация | Что |
|---------|------|------------|-----|
| `/v2/login` | server.js | Нет | Редирект на `/login?returnTo=/v2/app` |
| `/v2/iam` | server.js | Сессия | IAM-данные (отладка) |
| `/v2/app` | user/ | resolveContext | CRUD юзера: список, добавить, изменить, удалить |
| `/v2/admin` | admin/ | isAdmin | Дашборд компаний |
| `/v2/admin/audit` | admin/ | isAdmin | Аудит по компании |
| `/v2/admin/limit` | admin/ | isAdmin | POST — установить лимит |
| `/v2/admin/entries` | admin/ | isAdmin | Записи любой компании |
| `/v2/test` | test/ | Нет (флаг) | Тестовый API: `?action=add/list/edit/delete/audit/limit/switch/cleanup/userFlow` |
| `/v2/test/chaos` | test/ | Нет (флаг) | Параллельный хаос-тест (40 операций) |
| `/v2/logout` | server.js | Нет | Редирект на `/logout` |
### Цепочка вызовов (юзер добавляет CIDR)
```
Браузер: форма <form method="POST" action="/v2/app/add">
→ POST /v2/app/add
→ user/index.js router.post('/add')
→ crud.add(clientId, rawCidr, comment, email, impBy)
→ validators.validate(rawCidr)
→ db.getOrCreateCompany(clientId)
→ db.createEntry(companyId, validatedCidr, ...)
→ overlaps() — проверка дубликатов в БД
→ SQL INSERT v2_entries
→ SQL INSERT v2_audit
→ 302 /v2/app?msg=Добавлено
```
### Админка — возможности
| Функция | Маршрут | Через |
|---------|---------|-------|
| Список всех компаний | GET /v2/admin | q.getAllCompanies() |
| Аудит компании | GET /v2/admin/audit?companyId=X | q.getAudit() |
| Установка лимита | POST /v2/admin/limit | q.setLimit() |
| Записи компании | GET /v2/admin/entries?companyId=X | crud.list(clientId) |
Колонки аудита: Дата, Действие, Кто, От имени (impersonated_by), Компания, Значения (old→new).
### Тестовый слой
`/v2/test` — полный доступ ко всем слоям через curl, без KC-сессии:
| Action | Что тестирует | Слои |
|--------|--------------|------|
| `?action=add&cidr=X` | Добавление | test→crud→validate→db |
| `?action=edit&id=X&cidr=Y` | Изменение | test→crud→validate→db |
| `?action=delete&id=X` | Удаление | test→crud→db |
| `?action=list` | Список | test→crud→db |
| `?action=audit` | Аудит | test→db |
| `?action=userFlow&sub=add` | Полная эмуляция юзера | test→мок сессии→crud→db |
| `/chaos` | Параллельный (40 ops) | test→crud→db |
| `?action=cleanup` | Очистка | test→db (прямые DELETE) |
Флаг отключения: `ENABLE_TEST_API=false` — код остаётся, роутер не монтируется.
### Тесты — сводка
| Группа | Кол-во | Статус |
|--------|--------|--------|
| test.sh (curl) | 54 | ✅ |
| Chaos (параллельные) | 1 | ✅ |
| Router (resolveContext) | 9 | ✅ |
| **Всего** | **64** | **✅** |
### Деплой
git push → Gitea → Nubes UI redeploy → `whitelist.nodejsk8s.services.ngcloud.ru`
VM (italo.kube5s.ru) — НЕ используется для деплоя v2.
### Ключевые решения
1. **Слои — внутренние API**: не HTTP, не микросервисы, чистые функции в одном процессе
2. **validate() вызывается в crud/**: db/queries получает готовый CIDR, не вызывает validate
3. **crud/ резолвит clientId→companyId**: фронтенды не знают про внутренние ID БД
4. **test/ — отдельный вход**: мок-сессия, без KC, доступен только в dev
5. **ENABLE_TEST_API=false** — отключение без удаления кода
6. **v2_ префиксы**: на таблицах БД — v2_companies, v2_entries, v2_audit. При интеграции убрать.
7. **Сессия без v2_ префикса**: `req.session.user` — совместимо с основным приложением
### Что дальше
| # | Задача | Статус |
|---|--------|--------|
| 1 | export/ — выгрузка CIDR | ❌ |
| 2 | EJS-шаблоны вместо inline HTML | ❌ |
| 3 | Auth end-to-end (KC callback) | ❌ |
| 4 | Интеграция в основной код (убрать v2_) | ❌ |
### Известные ошибки (исправлены)
| Баг | Причина | Фикс | Версия |
|-----|---------|------|--------|
| 10.x CIDR → 45 ошибок chaos | 10.0.0.0/8 в BLOCKED | Префиксы 1114 | 0.5.89 |
| auditCount=0 | Проверяли только WZ10001 | Все 4 компании | 0.5.89 |
| 105/150 ошибок | 3 задачи × 15 > лимит 15 | 1 задача × 10 | 0.5.89 |
| /chaos → 302 /login | Роутер на /test/api | Сменили на /test | 0.5.88 |
| validate() в db/queries | Смешаны слои | Вынесен в crud/ | 0.5.91 |
+47 -38
View File
@@ -1,16 +1,23 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — роутер (монтируется в server.js как /v2)
// V2 — главный роутер (монтируется в server.js как /v2)
//
// СЕЙЧАС: показывает IAM-данные из сессии основного приложения.
// Вход — через основной /login (production KC).
// ЭТО: createV2Router() — собирает все v2-роутеры в один.
// ЗАЧЕМ: единственная точка входа v2 в основное приложение.
//
// ПОТОМ: будет свой /v2/login → /v2/callback через production KC.
// Тогда заработает login() из src/auth/index.js.
// МОНТАЖ В ОСНОВНОМ server.js:
// const { createV2Router } = require('./v2/server');
// app.use('/v2', createV2Router());
//
// Модули:
// router/ — resolveContext: admin/user/impersonation → req.v2_*
// user/ — обычный CRUD (пустышка)
// admin/ — админка (пустышка)
// ПОРЯДОК МАРШРУТОВ (важен!):
// 1. /login — редирект на основной OIDC-вход
// 2. /iam — отладка IAM-данных из сессии
// 3. /app — CRUD юзера (resolveContext + user роутер)
// 4. /admin — админка (resolveContext + admin роутер)
// 5. /test — тестовый API (если ENABLE_TEST_API !== 'false')
// 6. /logout — редирект на основной выход
//
// ВАЖНО: роутеры /app и /admin используют resolveContext middleware,
// которое проверяет сессию и выставляет req.clientId, req.email, ...
// ═══════════════════════════════════════════════════════════════════════════════
'use strict';
@@ -25,26 +32,50 @@ const { pool } = require('./src/db');
const { ensureSchema } = require('./src/db/schema');
function createV2Router() {
// Автосоздание таблиц при старте
// Автосоздание таблиц v2_companies, v2_entries, v2_audit при старте
ensureSchema(pool).catch(e => console.error('[v2:db] Schema error:', e.message));
const router = express.Router();
// ── GET /v2/login — редирект на основной вход ──────────────────────────
// ── GET /v2/login — редирект на основной вход (KC OIDC) ─────────────
router.get('/login', (req, res) => {
res.redirect('/login?returnTo=' + encodeURIComponent('/v2/app'));
});
// ── GET /v2/app — показать IAM-данные из сессии ────────────────────────
// ── GET /v2/iam — отладка: показать IAM-данные из сессии ────────────
router.get('/iam', (req, res) => {
const u = req.session && req.session.user;
if (!u) return res.send('<h2>Нет данных</h2><p><a href="/v2/login">Войти</a></p>');
res.send(debugPage(u, config.version));
});
res.send(`
<!DOCTYPE html>
// ── /v2/app — пользовательский CRUD (только с сессией) ──────────────
router.use('/app', resolveContext, createUserRouter());
// ── /v2/admin — админка (только для isAdmin=true) ────────────────────
router.use('/admin', resolveContext, createAdminRouter());
// ── GET /v2/ — редирект на /v2/app ──────────────────────────────────
router.get('/', (req, res) => res.redirect('/v2/app'));
// ── /v2/test — тестовый API (отключить: ENABLE_TEST_API=false) ──────
// Код остаётся, роутер не монтируется при ENABLE_TEST_API=false.
if (process.env.ENABLE_TEST_API !== 'false') {
const { createTestRouter } = require('./src/test');
router.use('/test', createTestRouter());
}
// ── GET /v2/logout — редирект на основной выход ─────────────────────
router.get('/logout', (req, res) => res.redirect('/logout'));
return router;
}
function debugPage(u, version) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>V2 — IAM</title>
<style>body{font-family:monospace;background:#111;color:#eee;padding:20px}pre{background:#1a1a2e;padding:15px;border-radius:8px}h2{color:#4fc3f7}.label{color:#888}</style></head><body>
<h2>IAM Response (v${config.version})</h2>
<h2>IAM Response (v${version})</h2>
<p><span class="label">Email:</span> ${u.email}</p>
<p><span class="label">clientId:</span> ${u.clientId}</p>
<p><span class="label">Компания:</span> ${u.companyName}</p>
@@ -53,29 +84,7 @@ function createV2Router() {
<p><span class="label">Компаний:</span> ${u.allClientIds ? u.allClientIds.length : 0}${(u.allClientIds || []).join(', ')}</p>
<hr><pre>${JSON.stringify(u, null, 2)}</pre>
<p><a href="/v2/logout">Выйти</a> | <a href="/v2/app">CRUD</a> | <a href="/v2/admin">Админка</a></p>
</body></html>`);
});
// ── /v2/app — обычный CRUD (только для авторизованных) ─────────────────
router.use('/app', resolveContext, createUserRouter());
// ── /v2/admin — админка (только для авторизованных) ────────────────────
router.use('/admin', resolveContext, createAdminRouter());
// ── GET /v2/ ─────────────────────────────────────────────────────────────
router.get('/', (req, res) => res.redirect('/v2/app'));
// ── /v2/test — тестовый эндпоинт (отключить: ENABLE_TEST_API=false) ──
if (process.env.ENABLE_TEST_API !== 'false') {
const { createTestRouter } = require('./src/test');
router.use('/test', createTestRouter());
}
// ── GET /v2/logout ───────────────────────────────────────────────────────
router.get('/logout', (req, res) => res.redirect('/logout'));
return router;
</body></html>`;
}
module.exports = { createV2Router };
+33 -12
View File
@@ -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
View File
@@ -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 };
+15 -5
View File
@@ -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',
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',
};
+38 -7
View File
@@ -1,23 +1,42 @@
// ═══════════════════════════════════════════════════════════════════════════════
// V2 — CRUD API-слой (чистые функции, без Express)
// V2 — CRUD API-слой
//
// Принимает clientId (W-номер), сам резолвит companyId через getOrCreateCompany.
// Фронтенд (user/) и тесты (test/) дергают эти функции.
// ЭТО: чистые функции — API между фронтендами и БД.
// НЕ: Express, HTTP, req/res, сессии.
//
// Сигнатуры:
// ЗАЧЕМ:
// 1. Фронтенды (user/, admin/, test/) не знают про SQL, транзакции, companyId.
// 2. Валидация вызывается ОДИН раз здесь, не дублируется в каждом фронтенде.
// 3. clientId (W-номер) → companyId (внутренний ID БД) резолвится здесь.
// 4. Каждую функцию можно тестировать изолированно, без Express.
//
// КОНТРАКТ:
// 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
// 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
View File
@@ -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
View File
@@ -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');
+24 -15
View File
@@ -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,
client_id VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255),
custom_limit INTEGER DEFAULT NULL CHECK (custom_limit IS NULL OR custom_limit >= 0),
custom_limit INTEGER DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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,
+37 -16
View File
@@ -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.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.user = u;
req.allClientIds = u.allClientIds || [];
req.impersonatedBy = isImpersonated ? u.email : null;
req.allClientIds = u.allClientIds || [req.clientId];
req.profiles = u.profiles || [];
req.companyName = u.companyName || u.clientId;
next();
}
+49 -19
View File
@@ -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
View File
@@ -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>`
+51 -7
View File
@@ -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 = [];