diff --git a/package.json b/package.json index 21b18b0..9eb1f80 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/v2/history/2026-06-12.md b/v2/history/2026-06-12.md index fabca54..a29dfb5 100644 --- a/v2/history/2026-06-12.md +++ b/v2/history/2026-06-12.md @@ -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) + +``` +Браузер: форма
+ → 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 | diff --git a/v2/server.js b/v2/server.js index 0f40145..ffe753e 100644 --- a/v2/server.js +++ b/v2/server.js @@ -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('

Нет данных

Войти

'); + res.send(debugPage(u, config.version)); + }); - res.send(` - + // ── /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 ` V2 — IAM -

IAM Response (v${config.version})

+

IAM Response (v${version})

Email: ${u.email}

clientId: ${u.clientId}

Компания: ${u.companyName}

@@ -53,29 +84,7 @@ function createV2Router() {

Компаний: ${u.allClientIds ? u.allClientIds.length : 0} — ${(u.allClientIds || []).join(', ')}


${JSON.stringify(u, null, 2)}

Выйти | CRUD | Админка

-`); - }); - - // ── /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; +`; } module.exports = { createV2Router }; diff --git a/v2/src/admin/index.js b/v2/src/admin/index.js index 1c2bb30..284e381 100644 --- a/v2/src/admin/index.js +++ b/v2/src/admin/index.js @@ -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 `; } +// ── msgHtml() — скрипт для показа зелёных/красных плашек ────────────── function msgHtml() { return `