From d81430dc9aa7e1b9026edb395bcf24d927f79943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 31 May 2026 09:12:38 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20AGENT.md=20=E2=80=94=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B7=D1=8E=D0=BC=D0=B5?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT.md | 427 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..907af38 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,427 @@ +# AGENT.md — Полное руководство по проекту IPWhiteList + +> Этот файл предназначен для AI-агентов и разработчиков без контекста. +> Читай этот файл **первым** перед любой задачей. +> Обновляй после значимых изменений. + +--- + +## 1. Суть проекта + +**IP WhiteList** — веб-приложение для управления белыми IP-списками (CIDR) по компаниям. + +- Каждая компания имеет свой список IPv4/CIDR записей. +- Есть лимит записей на компанию (по умолчанию 15, admin может менять). +- Есть одна роль admin (задаётся через env) и обычные пользователи. +- Аутентификация через JWT (RS256): в проде — внешний OIDC (Keycloak/Nubes auth-api), в dev — mock. +- Весь UI — server-side rendered (EJS). API — REST JSON. + +--- + +## 2. Репозиторий и деплой + +| Параметр | Значение | +|---|---| +| Репо (рабочий) | `https://gitea.services.ngcloud.ru/Nail/ipwhitelist-app.git` ветка `master` | +| Локальный путь | `/home/naeel/ipwhitelist-app` | +| Деплой URL | `https://white.nodejsk8s.dev.nubes.ru` | +| Платформа | Nubes k8s — редеплой через Nubes UI из Gitea после `git push` | +| Версия | `package.json` → поле `version` (сейчас `0.4.9`) | + +**Правило версий:** bump `version` в `package.json` при каждом значимом изменении. + +--- + +## 3. Стек + +``` +Node.js 18+ + Express 4 +EJS (server-side rendering) +PostgreSQL 17 (pg pool, SSL require) +jsonwebtoken RS256 +express-session + connect-pg-simple +helmet (CSP) +express-rate-limit +supertest (тесты) +``` + +--- + +## 4. Переменные окружения + +```env +NODE_ENV=development # production → отключает DEV_MODE, требует SESSION_SECRET +PORT=3000 + +# Auth +DEV_MODE=true # true → mock JWT, ЗАПРЕЩЕНО в production +DEV_SECRET= # опционально: пароль для /dev-login на staging +ADMIN_CLIENT_ID=WZ01112 # clientId пользователя с правами admin +JWT_ISSUER=mock-auth-api # issuer в JWT + +# OIDC (если DEV_MODE=false) +KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud +KC_CLIENT_ID= # пусто = mock mode +KC_CLIENT_SECRET= +APP_URL=http://localhost:3000 + +# База данных +DB_HOST=write.bde8229b-1381-4330-b24b-727ad73fcb44.dev.nubes.ru +DB_PORT=5432 +DB_NAME=ipwhitelist +DB_USER=super +DB_PASS= +DB_SSLMODE=require + +# Сессия +SESSION_SECRET= # обязателен в production (fail-fast при запуске) + +# Прочее +DEFAULT_LIMIT=15 # лимит записей по умолчанию (если нет custom_limit) +API_BASE=http://localhost:3000 # UI-слой → куда идти за /api/v1/* +``` + +--- + +## 5. Структура файлов — карта ответственности + +``` +server.js ← точка входа: helmet, session, монтирование роутеров +src/ + config.js ← MOCK_USERS, APP_VERSION, backUrl() + db.js ← pg Pool, checkConnection() + auth.js ← initAuth() → { middleware, issueMockToken, ... } + queries.js ← ВСЕ SQL-запросы — единственное место работы с БД + validators.js ← validate(cidr), overlaps(), aggregateCIDRs(), BLOCKED_RANGES + api/ + index.js ← createApiRouter({auth, q}) — монтируется на /api/v1 + middleware/ + bearerAuth.js ← requireBearer: нет заголовка → 401 JSON + routes/ + entries.js ← GET/POST/PATCH/DELETE /api/v1/entries + GET /api/v1/entries/export + admin.js ← GET /api/v1/companies, PATCH limit, GET /api/v1/audit + middleware/ + rateLimit.js ← mutationLimiter, exportLimiter, authLimiter + session.js ← createSessionMiddleware(pool) + csrf.js ← initCsrf() — НЕ используется в новом UI + csp.js ← CSP_DIRECTIVES для helmet + routes/ + export.js ← GET /export — требует авторизации (Bearer) + exp.js ← ⚠️ ВРЕМЕННЫЙ GET /exp — без авторизации (удалить после prod) +ui/ + index.js ← createUiRouter({auth, MOCK_USERS, authLimiter}) + api-client.js ← HTTP-клиент к /api/v1/* (берёт Bearer из session.token) + routes/ + auth.js ← GET/POST /login, /login-token, /logout + entries.js ← /, /add, /edit/:id, /delete/:id + admin.js ← /admin, /admin/limit/:id, /audit + export.js ← /export (UI-обёртка, проксирует к /api/v1/entries/export) +views/ + index.ejs ← главная: таблица записей + форма добавления + admin.ejs ← список компаний с лимитами (admin only) + audit.ejs ← журнал аудита (admin only) + ui-login.ejs ← страница входа (mock-пресеты + вставить Bearer вручную) + error.ejs ← 403/404/500 +sql/ + schema.sql ← DDL: CREATE TABLE IF NOT EXISTS (идемпотентно) +tests/ + integration.js ← интеграционные тесты (supertest + реальный PG) + run-tests.js ← runner +``` + +--- + +## 6. Поток запроса — HTTP → БД + +### 6.1 REST API запрос + +``` +HTTP Request + → server.js: helmet, session, cookieParser + → GET /exp → src/routes/exp.js (без auth, временный) ← ТОЛЬКО ЭТО без auth + → /api/v1/* → src/api/index.js + → requireBearer : нет заголовка Authorization: Bearer → 401 JSON + → auth.middleware : валидация JWT → req.user = { clientId, isAdmin, email, ... } + → /entries/* → src/api/routes/entries.js + → resolveCompany() : admin+?company=id → getCompanyById() | иначе getOrCreateCompany() + → q.*() : SQL через pg pool + → JSON response + → /companies, /audit → src/api/routes/admin.js + → apiRequireAdmin() : !isAdmin → 403 JSON + → q.*() +``` + +### 6.2 UI запрос (SSR) + +``` +HTTP Request + → server.js → ui/index.js (createUiRouter) + → createAuthRouter : /login, /login-token, /logout — публичные + → requireToken : нет session.token → redirect /login + → resolveUser : jwt.decode(token) → req.user (без верификации подписи) + → createEntriesRouter, createAdminRouter, createExportRouter + → ui/api-client.js : HTTP запрос к /api/v1/* с Bearer из session.token + → разбирает JSON ответ + → res.render('view.ejs', data) + → Если API вернул 401 → destroy session → redirect /login +``` + +--- + +## 7. req.user — структура + +После прохождения `auth.middleware` (API) или `resolveUser` (UI): + +```js +req.user = { + clientId: 'WZ01112', // из JWT claim ClientID (заглавная!) + companyId: 'uuid-...', // из JWT claim company_id + companyName: 'Nubes Admin', // из JWT claim company_name + email: 'admin@nubes.ru', // из JWT claim email + isAdmin: true, // clientId === ADMIN_CLIENT_ID +} +``` + +--- + +## 8. База данных — схема + +```sql +-- Компании (1 строка на WZ-номер, создаётся при первом входе) +companies ( + id SERIAL PRIMARY KEY, + client_id VARCHAR(64) UNIQUE NOT NULL, -- WZ01112, WZ01325 и т.д. + name VARCHAR(255), + custom_limit INTEGER DEFAULT NULL, -- NULL = использует DEFAULT_LIMIT + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +) + +-- Записи белого списка (soft delete через deleted_at) +whitelist_entries ( + id SERIAL PRIMARY KEY, + company_id INTEGER REFERENCES companies(id), + value_cidr VARCHAR(18), -- '192.168.1.0/24' + comment VARCHAR(255), + created_by VARCHAR(255), -- email + created_at TIMESTAMPTZ, + updated_by VARCHAR(255), + updated_at TIMESTAMPTZ, + deleted_by VARCHAR(255), + deleted_at TIMESTAMPTZ -- NULL = активная запись +) +-- INDEX: uq_entries_active_cidr(company_id, value_cidr) WHERE deleted_at IS NULL + +-- Аудит +audit_log ( + id SERIAL PRIMARY KEY, + user_email VARCHAR(255), + company_id INTEGER, + action VARCHAR(32), -- 'CREATE' | 'UPDATE' | 'DELETE' + old_value TEXT, + new_value TEXT, + entry_id INTEGER, + created_at TIMESTAMPTZ +) +``` + +--- + +## 9. src/queries.js — все функции + +| Функция | Сигнатура | Описание | +|---|---|---| +| `getOrCreateCompany` | `(clientId, companyName) → company` | UPSERT компании по clientId | +| `getCompanyById` | `(id) → company\|null` | по числовому PK | +| `getLimit` | `(company) → number` | custom_limit ?? DEFAULT_LIMIT | +| `listEntries` | `(companyId, includeDeleted=false) → []` | список записей | +| `createEntry` | `(companyId, rawCidr, comment, email) → {entry, wasNormalized}` | INSERT + audit (транзакция) | +| `updateEntry` | `(entryId, companyId, rawCidr, comment, email) → {entry, wasNormalized}` | UPDATE + audit (транзакция) | +| `deleteEntry` | `(entryId, companyId, email) → void` | soft delete + audit (транзакция) | +| `getAllCompanies` | `() → []` | все компании + active_count | +| `setLimit` | `(companyId, newLimit) → void` | custom_limit = newLimit | +| `getExportCIDRs` | `(companyId=null) → string[]` | CIDR строки; null = все компании | +| `logAudit` | `(email, companyId, action, old, new, entryId, db?) → void` | внутренняя, вызывается из транзакций | +| `getAudit` | `(companyId=null) → []` | журнал с JOIN companies | + +--- + +## 10. src/validators.js — функции + +```js +validate(rawValue) + → { cidr: '1.2.3.0/24', wasNormalized: boolean } + → throws Error если невалидно + // Нормализует host bits, проверяет BLOCKED_RANGES + +overlaps(cidr1, cidr2) → boolean + // true если диапазоны пересекаются + +aggregateCIDRs(cidrArray) → string[] + // Объединяет пересекающиеся и смежные диапазоны → минимальный набор + +BLOCKED_RANGES // приватные/зарезервированные диапазоны — запрещены для добавления +``` + +--- + +## 11. src/auth.js — initAuth() + +```js +const auth = await initAuth(); +// Возвращает: +auth.middleware // Express middleware: валидирует Bearer JWT → req.user +auth.issueMockToken(claims) → jwtString // только в mock-режиме, для тестов +auth.jwksHandler // GET /.well-known/jwks.json (только mock) +// OIDC-режим: +auth.getAuthUrl(state, nonce) → string // redirect URL на Keycloak +auth.exchangeCode(code) → {token, user} // обмен code → JWT +``` + +**Логика определения режима:** +- `KC_CLIENT_ID && KC_CLIENT_SECRET` → OIDC-режим +- иначе → mock-режим (генерирует RSA-пару при старте) + +--- + +## 12. ui/api-client.js — интерфейс + +```js +api.token(req) // → Bearer string из req.session.token +api.get(path, token) // → { status, data } +api.post(path, token, body) // → { status, data } +api.patch(path, token, body) // → { status, data } +api.delete(path, token) // → { status, data } +// path примеры: '/api/v1/entries', '/api/v1/entries/42', '/api/v1/companies/5/limit' +``` + +--- + +## 13. Все HTTP эндпоинты + +### Публичные (без auth) +| Метод | URL | Файл | Описание | +|---|---|---|---| +| GET | `/healthz` | server.js | k8s liveness probe | +| GET | `/.well-known/jwks.json` | server.js | mock JWKS (только DEV_MODE) | +| GET | `/login` | ui/routes/auth.js | страница входа | +| POST | `/login` | ui/routes/auth.js | выбор mock-пользователя | +| POST | `/login-token` | ui/routes/auth.js | вставить Bearer вручную | +| GET | `/logout` | ui/routes/auth.js | destroy session | +| GET | `/callback` | ui/routes/auth.js | OIDC callback | +| GET | `/exp` | src/routes/exp.js | ⚠️ ВРЕМЕННЫЙ: экспорт без auth | + +### UI (требуют session.token) +| Метод | URL | Файл | Описание | +|---|---|---|---| +| GET | `/` | ui/routes/entries.js | главная страница | +| POST | `/add` | ui/routes/entries.js | добавить запись | +| POST | `/edit/:id` | ui/routes/entries.js | изменить запись | +| POST | `/delete/:id` | ui/routes/entries.js | удалить запись | +| GET | `/admin` | ui/routes/admin.js | список компаний (admin) | +| POST | `/admin/limit/:id` | ui/routes/admin.js | изменить лимит (admin) | +| GET | `/audit` | ui/routes/admin.js | журнал аудита (admin) | +| GET | `/export` | ui/routes/export.js | скачать whitelist.txt | + +### REST API `/api/v1` (требуют Bearer JWT) +| Метод | URL | Файл | Кто | Описание | +|---|---|---|---|---| +| GET | `/api/v1/entries` | api/routes/entries.js | все | список + limit + used | +| GET | `/api/v1/entries?company=` | api/routes/entries.js | admin | записи другой компании | +| POST | `/api/v1/entries` | api/routes/entries.js | все | создать | +| PATCH | `/api/v1/entries/:id` | api/routes/entries.js | все | обновить | +| DELETE | `/api/v1/entries/:id` | api/routes/entries.js | все | удалить | +| GET | `/api/v1/entries/export` | api/routes/entries.js | все | text/plain CIDR | +| GET | `/api/v1/companies` | api/routes/admin.js | admin | все компании | +| PATCH | `/api/v1/companies/:id/limit` | api/routes/admin.js | admin | установить лимит | +| GET | `/api/v1/audit` | api/routes/admin.js | admin | журнал аудита | + +--- + +## 14. Rate limiting + +| Лимитер | Применяется к | Лимит | +|---|---|---| +| `authLimiter` | POST /login, /login-token, GET /callback | 10 req / 5 мин | +| `exportLimiter` | GET /export, GET /exp, GET /api/v1/entries/export | 20 req / мин | +| `mutationLimiter` | POST /add, /edit/:id, /delete/:id | 30 req / мин | + +--- + +## 15. Mock-пользователи (DEV_MODE=true) + +| id | clientId | Роль | companyName | email | +|---|---|---|---|---| +| `admin` | `WZ01112` | **admin** | Nubes Admin | admin@nubes.ru | +| `test` | `WZ01325` | user | Тест | tazet@narod.ru | +| `client2` | `WZ02001` | user | Вторая Компания | user2@example.com | + +Файл: `src/config.js` → `MOCK_USERS` + +--- + +## 16. Бизнес-логика — важные правила + +1. **Изоляция компаний**: везде `company_id` в WHERE — данные одной компании недоступны другой. +2. **Транзакции с FOR UPDATE**: createEntry/updateEntry/deleteEntry блокируют строку компании — защита от race condition при параллельных вставках. +3. **Soft delete**: `deleted_at IS NULL` = активная запись. Удалённые остаются в БД. +4. **Дубли/пересечения**: при создании и обновлении проверяются через `overlaps()` против всех активных записей компании. +5. **Лимит**: снижение лимита не удаляет записи — только блокирует создание новых. +6. **CIDR нормализация**: `validate()` нормализует host bits (192.168.1.5/24 → 192.168.1.0/24), возвращает `wasNormalized: true`. +7. **Агрегация при экспорте**: `aggregateCIDRs()` объединяет смежные диапазоны — минимальный набор. + +--- + +## 17. HTTP коды ошибок API + +| Код | Когда | +|---|---| +| 400 | Невалидный CIDR, некорректные параметры | +| 401 | Нет/невалидный Bearer токен | +| 403 | Не admin на admin-endpoint | +| 404 | Запись/компания не найдена | +| 409 | Дубликат CIDR, пересечение, лимит исчерпан | +| 500 | Ошибки БД и прочие | + +--- + +## 18. Запуск и тесты + +```bash +# Установка зависимостей +npm install + +# Запуск локально (нужен .env) +node server.js + +# Тесты (supertest + реальная БД) +npm test +# или +node tests/run-tests.js +``` + +Тесты требуют реального PG подключения (переменные из .env). + +--- + +## 19. ⚠️ ВРЕМЕННЫЕ ЭЛЕМЕНТЫ — удалить перед продом + +### GET /exp — публичный экспорт без авторизации (добавлен v0.4.9) + +**Причина:** По ТЗ п. 4.7 `/export` должен быть публичным, но сейчас требует авторизацию. Создан временный `/exp` как workaround. + +**Что удалить:** +1. Файл `src/routes/exp.js` — полностью +2. В `server.js` строку: `app.use(require('./src/routes/exp'));` + +**Когда:** когда `/export` сделают публичным по ТЗ. + +--- + +## 20. Расхождения с ТЗ (открытые задачи) + +| Пункт ТЗ | Суть | Статус | +|---|---|---| +| 3.1 | Несколько компаний на пользователя | Не реализовано — ждём devops | +| 4.1 | Отображение soft-deleted для admin | Не реализовано | +| 4.7 | `/export` без авторизации | Обход: `/exp` (временный) |