diff --git a/.env.example b/.env.example index b4d2bd4..54be83b 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ DB_SSLMODE=disable # true = mock-логин (без Keycloak, для тестов) # false = OIDC SSO через Keycloak (ОБЯЗАТЕЛЬНО в production) DEV_MODE=true + +# APP_ENV=production (по умолчанию) или test (жёлтый баннер в UI) +APP_ENV=test + DEFAULT_LIMIT=15 # ── Сессия (сгенерировать: openssl rand -base64 32) ── diff --git a/sql/migrations/001_add_impersonated_by.sql b/sql/migrations/001_add_impersonated_by.sql new file mode 100644 index 0000000..47968a4 --- /dev/null +++ b/sql/migrations/001_add_impersonated_by.sql @@ -0,0 +1,4 @@ +-- 001: Добавить колонку impersonated_by в audit_log +-- Дата: 2026-06 (была добавлена в ensureSchema вручную) + +ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS impersonated_by VARCHAR(255) DEFAULT NULL; diff --git a/sql/migrations/002_add_audit_restore.sql b/sql/migrations/002_add_audit_restore.sql new file mode 100644 index 0000000..35c6c2b --- /dev/null +++ b/sql/migrations/002_add_audit_restore.sql @@ -0,0 +1,18 @@ +-- 002: Добавить значение RESTORE в CHECK-констрейнт audit_log.action +-- Дата: 2026-06 (до этого CHECK допускал только CREATE, UPDATE, DELETE) + +-- Сначала удаляем старый CHECK, потом создаём новый со значением RESTORE +DO $$ +DECLARE + cn text; +BEGIN + SELECT con.conname INTO cn + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + WHERE rel.relname = 'audit_log' AND con.contype = 'c'; + IF cn IS NOT NULL THEN + EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn; + END IF; + EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))'; +END; +$$; diff --git a/sql/migrations/README.md b/sql/migrations/README.md new file mode 100644 index 0000000..417662d --- /dev/null +++ b/sql/migrations/README.md @@ -0,0 +1,16 @@ +# Миграции БД + +Каждый файл — одна миграция. Применяются при старте приложения автоматически в порядке сортировки имён. + +## Как добавить новую миграцию + +1. Создать файл `{номер}_{описание}.sql` в этой папке +2. Номер должен быть следующим (003, 004, ...) +3. SQL должен быть идемпотентным (`IF NOT EXISTS` где возможно) +4. Закоммитить и запушнуть + +## Важно + +- Миграции выполняются **один раз**. После применения имя файла записывается в таблицу `_migrations`. +- Уже работающие миграции не перезапускаются — если нужно что-то изменить, создавай новую миграцию, не редактируй старую. +- Все DDL должны быть безопасны для повторного запуска — используй `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`. diff --git a/v2/src/admin/index.js b/v2/src/admin/index.js index 9176d5b..3a329a2 100644 --- a/v2/src/admin/index.js +++ b/v2/src/admin/index.js @@ -31,7 +31,7 @@ function createAdminRouter({ generateCsrfToken } = {}) { try { const companies = await q.getAllCompanies(); res.render('v2/admin', { - companies, user: req.adminUser, version: config.version, + companies, user: req.adminUser, version: config.version, env: config.env, csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '', }); } catch (e) { @@ -52,7 +52,7 @@ function createAdminRouter({ generateCsrfToken } = {}) { res.render('v2/admin-audit', { audit: result.rows, total: result.total, page, pages: Math.ceil(result.total / perPage) || 1, - days, company, version: config.version, + days, company, version: config.version, env: config.env, }); } catch (e) { res.status(500).send('
' + e.message + ''); diff --git a/v2/src/config/index.js b/v2/src/config/index.js index ad67c09..e35b90a 100644 --- a/v2/src/config/index.js +++ b/v2/src/config/index.js @@ -8,7 +8,12 @@ // ═══════════════════════════════════════════════════════════════════════════════ module.exports = { - version: '0.1.11', + version: '0.1.12', + + // ── Среда ───────────────────────────────────────────────────────────────── + // APP_ENV=test → тестовый стенд (жёлтый баннер в UI, можно включать тестовый API) + // APP_ENV=production или не задана → production (баннера нет) + env: process.env.APP_ENV || 'production', // ── IAM ────────────────────────────────────────────────────────────────── iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user', diff --git a/v2/src/db/schema.js b/v2/src/db/schema.js index 0520111..8ee6864 100644 --- a/v2/src/db/schema.js +++ b/v2/src/db/schema.js @@ -1,20 +1,22 @@ // ═══════════════════════════════════════════════════════════════════════════════ -// V2 — инициализация схемы БД +// V2 — инициализация схемы БД + миграции // -// ЭТО: CREATE TABLE IF NOT EXISTS — безопасный повторный запуск. +// ЭТО: CREATE TABLE IF NOT EXISTS (идемпотентно) + applyMigrations(). // ЗАЧЕМ: -// 1. Приложение само создаёт таблицы при старте — не нужны миграции. -// 2. v2_ префикс изолирует тестовые таблицы от основных. -// 3. Безопасно для production: IF NOT EXISTS. +// 1. Приложение само создаёт таблицы при старте — не нужны ручные DDL. +// 2. Миграции — отдельные .sql файлы в sql/migrations/, выполняются по порядку. +// 3. Безопасно для production: IF NOT EXISTS + _migrations таблица. // // ТАБЛИЦЫ: -// companies — client_id (W-номер), name, custom_limit -// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил -// audit_log — все действия: CREATE/UPDATE/DELETE, с impersonated_by -// -// ИНДЕКСЫ: на company_id для быстрых SELECT + на unique(client_id). +// companies — client_id (W-номер), name, custom_limit +// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил +// audit_log — все действия: CREATE/UPDATE/DELETE/RESTORE, с impersonated_by +// _migrations — какие миграции уже выполнены // ═══════════════════════════════════════════════════════════════════════════════ +const fs = require('fs'); +const path = require('path'); + async function ensureSchema(pool) { await pool.query(` -- Компании: W-номер уникален, upsert через ON CONFLICT @@ -51,7 +53,7 @@ async function ensureSchema(pool) { id SERIAL PRIMARY KEY, user_email VARCHAR(255) NOT NULL, company_id INTEGER NOT NULL, - action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE','RESTORE')), + action VARCHAR(32) NOT NULL, old_value TEXT, new_value TEXT, entry_id INTEGER, @@ -62,24 +64,82 @@ async function ensureSchema(pool) { CREATE INDEX IF NOT EXISTS idx_audit_log_company_time ON audit_log(company_id, created_at DESC); - -- Миграция: добавить RESTORE в CHECK (если ещё нет) - DO $$ - DECLARE - cn text; - BEGIN - SELECT con.conname INTO cn - FROM pg_constraint con - JOIN pg_class rel ON rel.oid = con.conrelid - WHERE rel.relname = 'audit_log' AND con.contype = 'c'; - IF cn IS NOT NULL THEN - EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn; - END IF; - EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))'; - END; - $$; + -- Таблица учёта выполненных миграций + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) UNIQUE NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); `); console.log('[db] Schema ensured'); + + // Выполнить неприменённые миграции + await applyMigrations(pool); } -module.exports = { ensureSchema }; +/** + * Применить новые миграции из sql/migrations/. + * Читает все .sql файлы, сортирует по имени, выполняет те, + * которых ещё нет в таблице _migrations. + */ +async function applyMigrations(pool) { + const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations'); + if (!fs.existsSync(dir)) { + console.log('[db] No migrations dir, skipping'); + return; + } + + const files = fs.readdirSync(dir) + .filter(f => f.endsWith('.sql')) + .sort(); + + if (files.length === 0) return; + + // Какие уже выполнены + const done = new Set( + (await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename) + ); + + for (const file of files) { + if (done.has(file)) continue; + const sql = fs.readFileSync(path.join(dir, file), 'utf8'); + await pool.query(sql); + await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]); + console.log('[db] Migration applied:', file); + } +} + +async function initOldMigrations(pool) { + // Миграции, которые раньше были в ensureSchema() — теперь + // фиксируем их как уже выполненные, чтобы не потерять. + const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations'); + if (!fs.existsSync(dir)) return; + const files = fs.readdirSync(dir) + .filter(f => f.endsWith('.sql')) + .sort(); + if (files.length === 0) return; + + // Создать таблицу если ещё нет (на случай первого запуска с миграциями) + await pool.query(` + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) UNIQUE NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + + const done = new Set( + (await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename) + ); + + for (const file of files) { + if (done.has(file)) continue; + const sql = fs.readFileSync(path.join(dir, file), 'utf8'); + await pool.query(sql); + await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]); + console.log('[db] Migration applied:', file); + } +} + +module.exports = { ensureSchema, applyMigrations, initOldMigrations }; diff --git a/v2/src/user/index.js b/v2/src/user/index.js index 93c45de..d80994b 100644 --- a/v2/src/user/index.js +++ b/v2/src/user/index.js @@ -83,7 +83,7 @@ function createUserRouter({ generateCsrfToken } = {}) { activeClientId: clId, user: templateUser, includeDeleted, - version: config.version, + version: config.version, env: config.env, csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '', }); } catch (e) { diff --git a/views/v2/admin-audit.ejs b/views/v2/admin-audit.ejs index 0472de0..d35f94f 100644 --- a/views/v2/admin-audit.ejs +++ b/views/v2/admin-audit.ejs @@ -11,7 +11,8 @@ --red: #dc2626; --green: #16a34a; --amber: #d97706; } * { box-sizing: border-box; margin: 0; padding: 0; } - body { + .test-banner { background: #fef3cd; color: #856404; padding: 12px; text-align: center; font-weight: bold; border-bottom: 2px solid #f0b400; font-size: 14px; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; } @@ -53,6 +54,7 @@ +<% if (env === 'test') { %><% } %>