Files
ipwhitelist-app/AGENT.md
T

474 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.5.0`) |
**Правило версий:** 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=<secret>
DB_SSLMODE=require
# Сессия
SESSION_SECRET=<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=<id>` | 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 с DEV_MODE=true + БД)
node server.js
# API-тесты (104 штуки — CRUD, валидация, изоляция, admin, лимиты)
npm test
# Стресс-тесты (191 штука — concurrency, races, boundary IPs, masks, auth attacks)
npm run test:stress
# Старый UI-тест (неактуален, использует /dev-login + CSRF)
npm run test:integration
```
**Все тесты требуют реального PG подключения** (переменные из `.env`).
### Структура тестов (`tests/stress.js`):
| Раздел | Что проверяет |
|---|---|
| S1 | Concurrency: FOR UPDATE lock, 10-15 параллельных вставок |
| S2 | Limit enforcement под нагрузкой, setLimit(0) |
| S3 | Mixed CRUD: concurrent update+delete+create |
| S4 | Auth attacks: fake key, none algorithm, missing claims |
| S5 | Edge cases: emoji, null byte, null/''/undefined |
| S6 | Validator: overlaps, 14 blocked ranges |
| S7 | Session vs Bearer conflict (bearerMiddleware) |
| S8 | All CIDR masks /0/33 (34 теста) |
| S9 | IP boundary values: 0.0.0.0 … 255.255.255.255 (32 теста) |
| S10 | Token manipulation: expired, wrong issuer, future, no claims |
| S11 | DB: double delete, update deleted, isolation |
| S12 | Unicode: full-width, zero-width, RTL, NFC/NFD |
| S13 | Admin privilege escalation |
| S14 | Session: login→logout, cookie reuse |
| S15 | Rate limit /exp, /login |
| S16 | Export edge cases |
| S17 | Massive concurrency: 50 createEntry, 30 delete, 20 getOrCreate |
| S18 | CIDR normalization: host bits обнуление |
| S19 | Each blocked range individually (14) |
| S20 | Sequential CRUD patterns |
| S21 | Admin cross-company CRUD |
| S22 | Weird CIDR input: пробелы, табы, \n, 256, IPv6 (20) |
---
## 19. Известные проблемы (баги) — исправлены в v0.5.0
| Проблема | Статус |
|---|---|
| `auth.middleware` читал `req.session.user` перед Bearer → Bearer-only API нарушалось | ✅ Добавлен `bearerMiddleware` |
| Mock admin `isAdmin: undefined` в сессии | ✅ `user.role === 'admin'` |
| Сброс лимита (пустое поле → NaN → 400) | ✅ `null` при пустом поле |
| `setLimit()` не проверял `rowCount` → 404 на неизвестную компанию | ✅ 404 с `e.status` |
| Версия не поднималась при правках | ✅ `0.4.1``0.5.0` (9 бампов) |
| CSP/ingress блокировал inline-обработчики (onclick, onchange) | ✅ Event delegation через `<script>` |
| Admin при `/` редиректился на первую компанию, но не на свою | ✅ Авто-редирект на компанию admin по `clientId` |
| Даты в UTC вместо МСК | ✅ `timeZone: 'Europe/Moscow'` |
| Фильтр аудита: `onchange` ненадёжен | ✅ Кнопка «Применить» + `Number()` сравнение |
| `/admin` → 500 из-за отсутствия `defaultLimit` в шаблоне | ✅ Передаётся в `render()` |
## 20. ⚠️ ВРЕМЕННЫЕ ЭЛЕМЕНТЫ — удалить перед продом
### 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` сделают публичным по ТЗ.
---
## 21. Расхождения с ТЗ (открытые задачи)
| Пункт ТЗ | Суть | Статус |
|---|---|---|
| 3.1 | Несколько компаний на пользователя | Не реализовано — ждём devops |
| 4.1 | Отображение soft-deleted для admin | Не реализовано |
| 4.7 | `/export` без авторизации | Обход: `/exp` (временный) |