v2: подробные комментарии во всех модулях — что, зачем, контракты
This commit is contained in:
@@ -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 | Префиксы 11–14 | 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
@@ -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
@@ -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