diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 9ed960aa..7303d4cf 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -16,6 +16,10 @@ on: - ".forgejo/workflows/deploy.yml" - "data/sql/**" - "ops/glitchtip-auth-forwarder/**" + # Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом + # деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы + # до прода до следующего чужого коммита в backend/. + - "ops/db-bootstrap/**" workflow_dispatch: concurrency: @@ -320,6 +324,70 @@ jobs: echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader" fi + # ── БД `auth` — единое хранилище доступов «Меры» и «Птицы» ────────────── + # Расположение файлов: схема лежит в data/sql/auth/ (ПОДКАТАЛОГ, не плоский + # data/sql/) — цикл миграций выше использует `ls -1 data/sql/*.sql`, который в + # подкаталоги не рекурсирует. Значит эти файлы физически не могут примениться + # в БД gendesign, даже если кто-то забудет про разделение; при этом триггер + # `data/sql/**` (paths выше) подкаталог покрывает, деплой запускается сам. + # Свой _schema_migrations живёт ВНУТРИ БД auth: отдельная база — отдельный + # трекинг, имена файлов двух каталогов не конфликтуют между собой. + # Порядок: сразу после bootstrap'а FDW-пароля и ДО `compose up -d` — падение + # здесь останавливает деплой (exit 1) до подъёма нового кода. + # `source backend/.env.runtime` уже выполнен выше (строка с FDW-паролем), из него + # берётся AUTH_DB_PASSWORD. + echo "→ Bootstrapping auth database (idempotent)" + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=on \ + < ops/db-bootstrap/create_auth_db.sql \ + || { echo "FAILED to create auth database"; exit 1; } + + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on -c " + CREATE TABLE IF NOT EXISTS _schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + " + + for sql_file in $(ls -1 data/sql/auth/*.sql 2>/dev/null | sort); do + fname=$(basename "$sql_file") + # `| tr -d '[:space:]'` — как в deploy-tradein.yml: без него psql-вывод с + # лишним пробелом/CR ломает сравнение с "0" и миграция молча считается + # применённой. + applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -tAc \ + "SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" \ + | tr -d '[:space:]') + if [ "$applied" = "0" ]; then + echo "→ Applying auth migration: $fname" + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \ + < "$sql_file" \ + || { echo "FAILED on auth migration: $fname"; exit 1; } + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -c \ + "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;" + else + echo "✓ Already applied (auth): $fname" + fi + done + echo "All auth migrations applied." + + # Пароль роли auth_app из env (post-migration bootstrap): миграция + # data/sql/auth/002_auth_app_role.sql создаёт роль БЕЗ пароля, пароль живёт + # только в /opt/gendesign/backend/.env.runtime. Пустая переменная — не ошибка: + # PR-1 ещё никого не подключает к этой БД, роль просто остаётся без пароля. + if [ -n "${AUTH_DB_PASSWORD:-}" ]; then + echo "→ Applying auth_app password from env" + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \ + -v "pw=$AUTH_DB_PASSWORD" \ + < ops/db-bootstrap/set_auth_app_password.sql + else + echo "⚠️ AUTH_DB_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for auth_app" + fi + # Build local-only sidecar images (glitchtip-auth-forwarder). # Эти services не в GHCR — сборка происходит на VPS на каждом deploy. # Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись. diff --git a/backend/tests/sql/test_auth_sql_migrations.py b/backend/tests/sql/test_auth_sql_migrations.py new file mode 100644 index 00000000..492fcd84 --- /dev/null +++ b/backend/tests/sql/test_auth_sql_migrations.py @@ -0,0 +1,160 @@ +"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql). + +Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только +в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по +tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла +корневого каталога — не задача этого PR, поэтому здесь проверяются инварианты, которые +можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого +дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя. + +Тест не требует БД — только чтение файлов. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_AUTH_SQL_DIR = _REPO_ROOT / "data" / "sql" / "auth" +_BOOTSTRAP_SQL_DIR = _REPO_ROOT / "ops" / "db-bootstrap" +_DEPLOY_WORKFLOW = _REPO_ROOT / ".forgejo" / "workflows" / "deploy.yml" + +_FILENAME_RE = re.compile(r"^(\d{3})_[a-z0-9_]+\.sql$") + +# Признаки утёкшего пароля в git. bcrypt-хеши ($2a$/$2b$/$2y$) запрещены наравне с +# plaintext: хеш из репозитория брутфорсится офлайн и переживает ротацию пароля, +# оставаясь в истории коммитов. Конвенция репо — сид вставляет password_hash = NULL, +# значения проставляются на проде (прецедент: tradein м.193). +_SECRET_PATTERNS = ( + re.compile(r"\$2[aby]\$\d{2}\$"), # bcrypt hash + re.compile(r"PASSWORD\s+'", re.IGNORECASE), # CREATE/ALTER ROLE ... PASSWORD 'literal' +) + + +def _auth_sql_files() -> list[Path]: + """Файлы, к которым применимы конвенции миграций (имя NNN_*, обёртка BEGIN/COMMIT).""" + return sorted(_AUTH_SQL_DIR.glob("*.sql")) + + +def _secret_scanned_files() -> list[Path]: + """Файлы, по которым гоняется поиск паролей/хешей — ШИРЕ, чем список миграций. + + ⚠️ НЕ «унифицируй» этот список с _auth_sql_files(): разделение намеренное. + + * data/sql/auth/*.sql — миграции: обязаны иметь имя NNN_snake_case.sql и обёртку + BEGIN;/COMMIT; (см. test_filenames_and_unique_prefix, test_migrations_are_transactional). + * ops/db-bootstrap/*.sql — bootstrap: НЕ миграции, поэтому намеренно без NNN-префикса + (порядок задан явными шагами deploy.yml, не сортировкой) и намеренно без транзакции + (CREATE DATABASE запрещён внутри транзакционного блока). Прогонять по ним проверки + имён/BEGIN-COMMIT — значит сломать тест на корректных файлах. + + А вот запрет на пароли применим к обоим каталогам, и именно bootstrap здесь важнее: + единственное место в репозитории с конструкцией `ALTER ROLE ... PASSWORD` — это + ops/db-bootstrap/set_*_password.sql, то есть ровно тот файл, куда проще всего однажды + «временно» вписать литерал вместо чтения из env. Другого контроля на это нет: + в .pre-commit-config.yaml из секрет-сканеров только detect-private-key (bcrypt не ловит), + а репо-wide grep невозможен — caddy/users.caddy.snippet легально содержит bcrypt-хеши + действующих логинов. + """ + return _auth_sql_files() + sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")) + + +def test_scanned_dirs_are_not_empty() -> None: + """Sanity: пути до каталогов не разъехались (иначе все проверки ниже — пустые). + + Red => каталог переименован/перенесён, а тест этого не заметил бы: `glob` по + несуществующему пути возвращает [], и все циклы ниже стали бы no-op'ами, оставаясь + зелёными. Особенно опасно для проверки паролей — «зелено, потому что ничего не проверено». + """ + assert _auth_sql_files(), f"Не найдено *.sql в {_AUTH_SQL_DIR}" + assert sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")), f"Не найдено *.sql в {_BOOTSTRAP_SQL_DIR}" + + +def test_filenames_and_unique_prefix() -> None: + """Имя вида NNN_snake_case.sql, префикс NNN уникален. + + Red => прод применяет файлы в порядке `ls | sort`; два файла с одним NNN дают + неоднозначный порядок (например, роль/гранты раньше таблиц). Присвой следующий + свободный номер. + """ + seen: dict[str, str] = {} + bad_names: list[str] = [] + collisions: list[str] = [] + for path in _auth_sql_files(): + m = _FILENAME_RE.match(path.name) + if m is None: + bad_names.append(path.name) + continue + prefix = m.group(1) + if prefix in seen: + collisions.append(f"{path.name} (префикс {prefix} уже у {seen[prefix]})") + else: + seen[prefix] = path.name + + assert not bad_names, ( + f"Имена не соответствуют NNN_snake_case.sql: {bad_names}. " + "Порядок применения на проде определяется сортировкой имён." + ) + assert not collisions, "Дублирующийся NNN-префикс: " + "; ".join(collisions) + + +def test_migrations_are_transactional() -> None: + """Каждая миграция обёрнута в BEGIN; ... COMMIT; (.claude/rules/sql.md). + + Red => частично применённая миграция оставит БД auth в промежуточном состоянии: + деплой падает на ON_ERROR_STOP, а уже выполненный DDL не откатывается. + """ + broken: list[str] = [] + for path in _auth_sql_files(): + text = path.read_text(encoding="utf-8") + statements = [ + line.strip() + for line in text.splitlines() + if line.strip() and not line.strip().startswith("--") + ] + if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;": + broken.append(path.name) + assert ( + not broken + ), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)." + + +def test_no_password_material_in_auth_sql() -> None: + """Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей. + + Покрытие шире каталога миграций сознательно — обоснование в _secret_scanned_files(). + + Red => пароль/хеш попал в git. Убери значение: сид вставляет password_hash = NULL, + пароль роли ставится из env через ops/db-bootstrap/set_auth_app_password.sql + (значение приезжает из .env.runtime на VPS и в репозитории не существует). + """ + hits: list[str] = [] + for path in _secret_scanned_files(): + rel = path.relative_to(_REPO_ROOT).as_posix() + text = path.read_text(encoding="utf-8") + for line_no, line in enumerate(text.splitlines(), start=1): + if line.lstrip().startswith("--"): + continue # комментарии описывают запрет, а не нарушают его + for pattern in _SECRET_PATTERNS: + if pattern.search(line): + hits.append(f"{rel}:{line_no}: {line.strip()}") + assert not hits, "Похоже на пароль/хеш в SQL: " + "; ".join(hits) + + +def test_deploy_workflow_applies_auth_migrations() -> None: + """deploy.yml реально прогоняет data/sql/auth/*.sql. + + Каталог обособлен намеренно: основной цикл миграций использует `ls -1 data/sql/*.sql` + и в подкаталоги НЕ рекурсирует (чтобы файлы auth физически не могли примениться в БД + gendesign). Обратная сторона — без отдельного цикла в deploy.yml эти файлы не + применяются вообще и никто этого не заметит. Red => wiring удалён или переименован. + """ + workflow = _DEPLOY_WORKFLOW.read_text(encoding="utf-8") + assert "data/sql/auth/*.sql" in workflow, ( + f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth " + "не применяются на деплое." + ) + assert ( + "ops/db-bootstrap/create_auth_db.sql" in workflow + ), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth." diff --git a/data/sql/auth/001_identity_schema.sql b/data/sql/auth/001_identity_schema.sql new file mode 100644 index 00000000..f6803113 --- /dev/null +++ b/data/sql/auth/001_identity_schema.sql @@ -0,0 +1,123 @@ +-- auth/001: users + sessions — единое хранилище доступов для «Меры» и «Птицы». +-- +-- WHY (почему отдельная БД и почему таблицы называются нейтрально): +-- Владелец продукта решил (2026-07-31) свести вход в «Меру» (trade-in, /trade-in) и +-- «Птицу» (раздел Site Finder, /site-finder/analysis/[cad]/ptica) к ОДНОЙ нейтральной +-- форме входа, вместо браузерного popup'а Caddy basic_auth. Значит, у хранилища доступов +-- два потребителя, и оно не должно принадлежать ни одному из них: живёт в отдельной БД +-- `auth` на платформенном сервере gendesign-postgres (тот же кластер, отдельная база — +-- новый контейнер не заводим; оба бэкенда сидят в сети gendesign_shared и TCP-достают +-- до gendesign-postgres-1:5432, проверено на проде 2026-07-31). +-- Отсюда имена без префикса продукта: `users`, а не `tradein_users`. Префикс продукта в +-- нейтральном хранилище означал бы, что вторая система — гость в чужой таблице, и через +-- полгода никто бы не помнил, кто владелец схемы. +-- +-- Здесь НЕТ колонки `role` — сознательно. Идентичность («кто это, какой у него пароль, +-- активен ли доступ») общая для двух продуктов; полномочия внутри продукта (admin/manager/ +-- employee в «Мере», админ-роуты в «Птице») — это знание продукта, оно остаётся в +-- продуктовых БД (tradein_users.role) и не переезжает сюда. Иначе `auth` пришлось бы +-- менять каждый раз, когда в одном из продуктов появляется новая роль. +-- +-- WHAT: +-- 1. users — identity. password_hash NULL допустим (см. комментарий к колонке): пароли +-- НИКОГДА не попадают в git, ни plaintext, ни bcrypt-хешем — конвенция репо, прецедент +-- tradein-mvp/backend/data/sql/193_tradein_users_seed.sql. Сид (003) вставляет строки +-- с password_hash = NULL, хеши проставляются на проде отдельно. +-- 2. sessions — токен-based сессии, ON DELETE CASCADE от users (удалили пользователя — +-- его сессии теряют смысл). last_seen_at отдельно от created_at — для idle-timeout, +-- иначе «сессия жива 30 дней» и «человек не заходил 30 дней» неразличимы. +-- 3. ASCII-CHECK на username — обязателен ДО появления прод-данных (см. ниже). +-- +-- IDEMPOTENCY: +-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS; CHECK-констрейнты объявлены +-- inline в CREATE TABLE, а не через ALTER — при повторном прогоне CREATE TABLE не +-- выполняется вообще, значит констрейнт физически не может задублироваться (паттерн из +-- 192_tradein_users_auth.sql). +-- +-- Тип id: `bigint GENERATED ALWAYS AS IDENTITY` — стандартный (SQL-standard) эквивалент +-- bigserial: та же bigint-колонка на той же последовательности, но sequence принадлежит +-- таблице жёстко и не переживает DROP COLUMN сиротой, а прямой INSERT в id запрещён +-- (случайная вставка «своего» id, ломающая счётчик, невозможна). Ровно так объявлен +-- tradein_users.id в 192 — держим один тип на обе таблицы, чтобы будущий код, читающий +-- обе, не спотыкался о разницу. +-- +-- Dependencies: нет (пустая БД `auth`, создаётся bootstrap-шагом деплоя, +-- см. ops/db-bootstrap/create_auth_db.sql). +-- Deploy order: Foundation. Роль приложения + гранты — 002, сид — 003. Python-код логина, +-- логин-страница и снятие Caddy basic_auth — отдельные PR'ы ПОСЛЕ этого +-- (SQL-схема первой, см. .claude/rules/sql.md «Migration order»). + +BEGIN; + +CREATE TABLE IF NOT EXISTS users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + username text NOT NULL UNIQUE, + password_hash text NULL, + display_name text NULL, + org_name text NULL, + email text NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$') +); + +COMMENT ON TABLE users IS + 'Единое хранилище доступов для «Меры» (trade-in) и «Птицы» (Site Finder) — только ' + 'идентичность. Полномочия внутри продукта (роли) остаются в продуктовых БД: иначе эту ' + 'таблицу пришлось бы менять при каждом изменении ролевой модели любого из продуктов.'; + +COMMENT ON COLUMN users.password_hash IS + 'NULL = пароль ещё не проставлен, вход по паролю для этой строки невозможен. Хеши ' + 'НИКОГДА не хранятся в git (ни в сидах, ни в фикстурах) — их проставляют на проде ' + 'отдельно от миграции; иначе один утёкший коммит открывает вход всем аккаунтам сразу.'; + +COMMENT ON COLUMN users.is_active IS + 'false = доступ закрыт владельцем продукта. Отдельная колонка, а не удаление строки: ' + 'удаление каскадом снесло бы сессии и историю, а закрытие доступа обратимо и его надо ' + 'уметь отличать от «такого пользователя никогда не было».'; + +COMMENT ON COLUMN users.org_name IS + 'Организация пользователя. NULL, пока реальные данные не подтверждены владельцем ' + 'продукта — выдуманное название хуже пустого, оно выглядит достоверным.'; + +COMMENT ON CONSTRAINT users_username_ascii_ck ON users IS + 'Fail-closed запрет не-ASCII логинов (перенесено из tradein м.193, deep-review #2561): ' + 'downstream-код кодирует username сессии через encode("latin-1","replace"), поэтому два ' + 'кириллических логина ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку из «?» — ' + 'разные люди получают общую идентичность, общую квоту и взаимный IDOR (один видит данные ' + 'другого). Констрейнт на уровне схемы, а не проверка в UI/API: проверку в коде однажды ' + 'забудут добавить в новый путь создания пользователя, схему обойти нельзя.'; + +CREATE TABLE IF NOT EXISTS sessions ( + token text PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + last_seen_at timestamptz NOT NULL DEFAULT now(), + ip_address inet NULL, + user_agent text NULL +); + +COMMENT ON TABLE sessions IS + 'Активные сессии единой формы входа (общие для «Меры» и «Птицы»). ON DELETE CASCADE от ' + 'users: оставшаяся сессия удалённого пользователя — это действующий доступ без владельца.'; + +COMMENT ON COLUMN sessions.last_seen_at IS + 'Обновляется на каждом запросе — нужен для idle-timeout: без него «сессия не истекла» и ' + '«человек ещё работает» неразличимы, и забытая открытая вкладка живёт до expires_at.'; + +COMMENT ON COLUMN sessions.ip_address IS + 'IP на момент выдачи токена — для разбора инцидентов («откуда зашли под этим логином»), ' + 'не для авторизации: привязка к IP ломает мобильных пользователей при смене сети.'; + +-- Индексы — как в tradein м.192: уборка протухших сессий по expires_at и выборка/отзыв +-- всех сессий одного пользователя по user_id (FK сам по себе индекс не создаёт, а без него +-- ON DELETE CASCADE на users делает seq scan по всей таблице сессий). +CREATE INDEX IF NOT EXISTS sessions_expires_at_idx + ON sessions (expires_at); + +CREATE INDEX IF NOT EXISTS sessions_user_id_idx + ON sessions (user_id); + +COMMIT; diff --git a/data/sql/auth/002_auth_app_role.sql b/data/sql/auth/002_auth_app_role.sql new file mode 100644 index 00000000..48dfe277 --- /dev/null +++ b/data/sql/auth/002_auth_app_role.sql @@ -0,0 +1,82 @@ +-- auth/002: роль приложения auth_app + гранты (least privilege). +-- +-- WHY: +-- Миграции этой БД прогоняются суперюзером кластера ($POSTGRES_USER), он же владелец +-- таблиц. Бэкенды «Меры» и «Птицы» ходить под суперюзером не должны: скомпрометированный +-- бэкенд не обязан уметь DROP TABLE users. Поэтому отдельная login-роль с точечными +-- грантами. БД `auth` НЕ принадлежит auth_app (владелец — суперюзер): владелец таблицы +-- имеет на неё все права независимо от GRANT'ов, и разграничение ниже стало бы фикцией. +-- +-- Пароль роли здесь НЕ задаётся — роль создаётся passwordless, пароль ставится отдельным +-- bootstrap-шагом деплоя из env (AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime, +-- см. ops/db-bootstrap/set_auth_app_password.sql). Ровно тот же паттерн, что у +-- gendesign_reader (tradein м.101 + set_gendesign_reader_password.sql) и tradein_fdw_reader +-- (data/sql/100_tradein_fdw_role.sql). Пароль в git не попадает ни при каких условиях. +-- +-- Периметр прав (обосновано по-операционно): +-- sessions — SELECT/INSERT/UPDATE/DELETE. Полный набор: выдать токен (INSERT), проверить +-- на каждом запросе (SELECT), обновить last_seen_at (UPDATE), разлогинить и вычистить +-- протухшие (DELETE). +-- users — SELECT (найти по username, прочитать hash и is_active) + UPDATE (смена пароля +-- самим пользователем и проставление хеша админом). +-- users — INSERT/DELETE НЕ выдаются, сознательно: +-- * INSERT — создание аккаунтов в PR-1 не существует ни как код, ни как UI. Выдать грант +-- «на будущее» = держать открытой операцию, которой никто не пользуется и которую никто +-- не тестирует. Когда появится админский путь создания пользователей, грант добавляется +-- новой миграцией в одну строку (плюс GRANT USAGE на sequence, идентичность требует +-- nextval). Обратная ошибка дороже: снять грант, на который уже опирается прод-код, +-- нельзя без синхронного релиза. +-- * DELETE — не выдаётся и дальше: закрытие доступа делается через is_active = false +-- (см. комментарий к колонке в 001). Физическое удаление каскадом сносит сессии и +-- обрывает связь с историей действий пользователя в продуктовых БД, где user_id/username +-- остаются висеть; это операция уровня «руками через psql с осознанием последствий», +-- а не то, что должен уметь HTTP-хендлер. +-- +-- IDEMPOTENCY: +-- CREATE ROLE через DO-блок с проверкой pg_roles (нет ADD ROLE IF NOT EXISTS), GRANT/REVOKE +-- идемпотентны по определению. Повторный прогон — no-op. Роли в PostgreSQL общие на кластер, +-- поэтому DO-блок отработает корректно, даже если роль уже создана из другой БД. +-- +-- Dependencies: 001_identity_schema.sql (гранты ссылаются на users/sessions). + +BEGIN; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN + CREATE ROLE auth_app LOGIN; + END IF; +END$$; + +COMMENT ON ROLE auth_app IS + 'Прикладная роль единой формы входа («Мера» + «Птица»). Пароль ставится ' + '.forgejo/workflows/deploy.yml из env AUTH_DB_PASSWORD (backend/.env.runtime) через ' + 'ops/db-bootstrap/set_auth_app_password.sql. Пароль никогда не хранится в SQL-миграциях.'; + +-- Никто, кроме владельца БД и явно поименованных ролей, не должен даже подключаться: +-- по умолчанию PostgreSQL даёт CONNECT роли PUBLIC, то есть любая login-роль кластера +-- (glitchtip, tradein_fdw_reader, gendesign_reader) может открыть сессию в `auth`. +-- Хранилище паролей — не то место, где стоит полагаться на «а таблицы им всё равно не видны». +-- +-- ЭТА СТРОКА ПРОДУБЛИРОВАНА в ops/db-bootstrap/create_auth_db.sql — намеренно, инвариант +-- держится в двух местах. Здесь — ради самодостаточности миграции: применённая на пустую БД +-- (scratch/staging, ручной psql -f) она обязана давать полный периметр прав, не полагаясь на +-- то, что кто-то отдельно прогнал bootstrap. В bootstrap — ради переприменяемости: миграция +-- выполняется РОВНО ОДИН РАЗ (трекинг в _schema_migrations), а БД может быть пересоздана из +-- дампа в обход миграций, и тогда дефолтный PUBLIC-CONNECT вернулся бы молча. Не «сокращай» +-- дубль — ни одна из копий не покрывает сценарий другой. +REVOKE ALL ON DATABASE auth FROM PUBLIC; + +-- Defense-in-depth: явный REVOKE-периметр перед точечными грантами — любые унаследованные +-- или PUBLIC-гранты на существующих объектах обнуляются (паттерн из 100_tradein_fdw_role.sql). +REVOKE ALL ON ALL TABLES IN SCHEMA public FROM auth_app; +REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM auth_app; +REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM auth_app; + +GRANT CONNECT ON DATABASE auth TO auth_app; +GRANT USAGE ON SCHEMA public TO auth_app; + +GRANT SELECT, INSERT, UPDATE, DELETE ON sessions TO auth_app; +GRANT SELECT, UPDATE ON users TO auth_app; + +COMMIT; diff --git a/data/sql/auth/003_users_seed.sql b/data/sql/auth/003_users_seed.sql new file mode 100644 index 00000000..6559d88d --- /dev/null +++ b/data/sql/auth/003_users_seed.sql @@ -0,0 +1,111 @@ +-- auth/003: сид 13 существующих аккаунтов (org-карта владельца продукта, 2026-07-30/31). +-- +-- WHY: +-- 001 создала схему, но без данных единая форма входа не заработает: реальные аккаунты +-- сейчас живут только в Caddy basic_auth (caddy/users.caddy.snippet + tradein auth/roles.yaml) +-- и в tradein_users. Эта миграция переносит список людей — БЕЗ ЕДИНОГО ПАРОЛЯ. +-- +-- password_hash = NULL у ВСЕХ строк. Это конвенция репо, а не недоделка: ни plaintext, ни +-- bcrypt-хеш не должны попадать в git (прецедент — tradein-mvp/backend/data/sql/ +-- 193_tradein_users_seed.sql, там сид тоже вставляет NULL, хеши проставляются отдельно на +-- проде). Хеш в git — это офлайн-brute-force для любого, кто получил доступ к репозиторию, +-- и он переживает любую ротацию пароля в истории коммитов. +-- Пока hash = NULL, вход по паролю через новую форму для строки невозможен, но доступ НЕ +-- теряется: PR-1 ничего не переключает, прод продолжает пускать через существующий +-- Caddy basic_auth ровно как сейчас. Переключение — отдельные PR'ы. +-- +-- Состав (утверждён владельцем продукта): +-- admin — владелец +-- kopylov — отдельный клиент, display_name «Копылов» +-- praktika — ГК «Практика» +-- user1, user3..user10 — свободные слоты, is_active = true +-- user2 — «Брусника», is_active = FALSE (доступ закрыт 2026-07-30); +-- в roles.yaml он role=expired — расхождение семантики, +-- см. ⚠️ у строки user2 в VALUES ниже +-- display_name заполнен только у kopylov (единственная фамилия, подтверждённая в коде: +-- tradein auth.py::_USERNAME_PROFILE). Остальным NULL — реальных данных нет, выдумывать +-- нельзя: выдуманное ФИО в UI неотличимо от настоящего. +-- QA-фикстуры НЕ мигрируются — им нечего делать в общем хранилище доступов двух продуктов. +-- Состав фикстур неоднороден, и это важно при сверке списков (проверено по обоим файлам): +-- admintest, pilottest — действующие логины: есть И в caddy/users.caddy.snippet +-- (basic_auth-запись с хешем), И в auth/roles.yaml (role-mapping). Реально входят. +-- analysttest, expiredtest — существуют ТОЛЬКО в auth/roles.yaml как role-mapping, +-- basic_auth-записи в caddy/users.caddy.snippet у них нет, то есть войти под ними +-- снаружи сегодня нельзя вообще. Это тестовые фикстуры, а не аккаунты: analysttest +-- гоняется в backend/tests (test_rbac.py, test_insights.py, test_audit_middleware.py), +-- expiredtest — в tradein-mvp/backend/tests/test_rbac.py как покрытие role=expired. +-- +-- IDEMPOTENCY (логика и обоснование перенесены из tradein м.193, deep-review #2564): +-- INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, display_name, +-- org_name, email защищены COALESCE(текущее, EXCLUDED). Если админ уже проставил пароль или +-- поправил профиль между двумя прогонами файла (обычный auto-apply трекает filename в +-- _schema_migrations и не запускает файл дважды на одном окружении — но ручной re-apply при +-- recovery и scratch/staging БД такого трекинга не имеют), повторный прогон НЕ должен +-- затереть это состояние NULL-ом. В м.193 это был живой баг: назначенный через API manager_id +-- тихо обнулялся повторным прогоном сида. +-- Направление COALESCE односторонее: NULL в БД можно дозаполнить значением из сида, но +-- значение из БД никогда не перетирается сидом. +-- +-- is_active НАМЕРЕННО отсутствует в SET — и не как COALESCE тоже: колонка NOT NULL, значит +-- COALESCE(NOT NULL-значение, x) никогда не возьмёт x, это был бы мёртвый код с видимостью +-- защиты. Открытие/закрытие доступа — решение владельца продукта, оно принимается в +-- интерфейсе, а не повторным прогоном seed-файла: после первой вставки колонка сознательно +-- «замораживается» на текущем значении в БД. +-- (В м.193 в SET присутствовал ещё role — как источник истины org-карты. Здесь колонки role +-- нет вовсе: полномочия остаются в продуктовых БД, см. заголовок 001.) +-- +-- updated_at = now() выставляется на любом конфликте, даже когда ни одна колонка фактически +-- не изменилась — паритет с м.193; «строка была затронута прогоном сида» это честно отражает. +-- +-- Разрывы в users.id после повторного прогона — норма, НЕ следы удалённых строк. Дефолт +-- GENERATED ALWAYS AS IDENTITY вычисляется ДО обнаружения конфликта, поэтому каждый +-- повторный прогон сжигает 13 значений последовательности впустую. Функционально безвредно; +-- упомянуто, чтобы дыры в id не увели разбор инцидента в сторону «кого-то удалили». +-- +-- Dependencies: 001_identity_schema.sql (users + ASCII-CHECK на username; все логины ниже +-- ASCII, констрейнту не противоречат). + +BEGIN; + +INSERT INTO users (username, password_hash, display_name, org_name, email, is_active) +VALUES + ('admin', NULL, NULL, NULL, NULL, true), + ('kopylov', NULL, 'Копылов', NULL, NULL, true), + ('praktika', NULL, NULL, NULL, NULL, true), + ('user1', NULL, NULL, NULL, NULL, true), + -- user2 — «Брусника», доступ закрыт владельцем продукта 2026-07-30. + -- + -- ⚠️ ОТКРЫТАЯ РАЗВИЛКА, решается в PR-2/3 (переключение на единую форму входа), НЕ здесь: + -- сегодня в auth/roles.yaml у user2 role=expired, и семантика ДРУГАЯ, чем is_active=false. + -- expired != disabled: expired-юзер проходит гейт (basic_auth-запись в + -- caddy/users.caddy.snippet у него есть), доходит до фронта и видит осмысленный экран + -- «пробный доступ закончился» (roles.yaml → блок expired: paths: [] + deny "/**"; + -- frontend NoAccessScreen variant="trial"). is_active=false — это отказ на этапе входа, + -- неотличимый для пользователя от «неверный пароль». + -- Сейчас расхождение безобидно: PR-1 ничего не переключает, прод по-прежнему ходит через + -- Caddy basic_auth + roles.yaml, и никакой код эту колонку не читает. Но в момент + -- переключения trial-экран пропадёт МОЛЧА — тесты не упадут, роль просто перестанет + -- существовать как состояние. Решать тогда: если trial-UX сохраняем, нужно отдельное + -- состояние (колонка status / отдельная роль), а не булев флаг — is_active схлопывает + -- «доступ закрыт» и «пробный период истёк» в одно значение. Схему в этом PR НЕ трогаем. + ('user2', NULL, NULL, NULL, NULL, false), + ('user3', NULL, NULL, NULL, NULL, true), + ('user4', NULL, NULL, NULL, NULL, true), + ('user5', NULL, NULL, NULL, NULL, true), + ('user6', NULL, NULL, NULL, NULL, true), + ('user7', NULL, NULL, NULL, NULL, true), + ('user8', NULL, NULL, NULL, NULL, true), + ('user9', NULL, NULL, NULL, NULL, true), + ('user10', NULL, NULL, NULL, NULL, true) +ON CONFLICT (username) DO UPDATE SET + -- COALESCE(текущее, EXCLUDED): сид дозаполняет пустые поля, но никогда не затирает + -- уже проставленные вручную (в первую очередь password_hash — иначе повторный прогон + -- отключал бы вход всем, кому пароль уже выдали). + password_hash = COALESCE(users.password_hash, EXCLUDED.password_hash), + display_name = COALESCE(users.display_name, EXCLUDED.display_name), + org_name = COALESCE(users.org_name, EXCLUDED.org_name), + email = COALESCE(users.email, EXCLUDED.email), + -- is_active НЕ в SET: NOT NULL-колонка, COALESCE был бы мёртвым кодом (см. IDEMPOTENCY). + updated_at = now(); + +COMMIT; diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md index cdcc39d5..8a913a42 100644 --- a/docs/Secrets_Rotation_Policy.md +++ b/docs/Secrets_Rotation_Policy.md @@ -21,7 +21,7 @@ | **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner | | **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) | | **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) | -| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb | +| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `AUTH_DB_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb | | **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек | | **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy | | **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton | @@ -62,6 +62,7 @@ | `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) | | `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** | | `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** | +| `AUTH_DB_PASSWORD` | `backend/.env.runtime` | Пароль роли `auth_app` — БД `auth` на gendesign-postgres (единое хранилище доступов «Меры» и «Птицы»). Применяется через `ops/db-bootstrap/set_auth_app_password.sql` на деплое. Переменная задаётся на VPS вручную; пока не задана — шаг пропускается с warning'ом | **E** | | `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** | | `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** | | `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** | diff --git a/ops/db-bootstrap/create_auth_db.sql b/ops/db-bootstrap/create_auth_db.sql new file mode 100644 index 00000000..b94dd4ef --- /dev/null +++ b/ops/db-bootstrap/create_auth_db.sql @@ -0,0 +1,67 @@ +-- Создание БД `auth` — единого хранилища доступов «Меры» и «Птицы» (идемпотентно). +-- +-- Applied by .forgejo/workflows/deploy.yml ПЕРЕД миграциями data/sql/auth/*.sql: +-- docker compose ... exec -T postgres psql -U "$POSTGRES_USER" -d postgres \ +-- -v ON_ERROR_STOP=on < ops/db-bootstrap/create_auth_db.sql +-- Подключение обязательно к БД `postgres`: нельзя создать базу, находясь в ней самой. +-- +-- ПОЧЕМУ ЭТО НЕ МИГРАЦИЯ: +-- CREATE DATABASE запрещён внутри транзакционного блока, а .claude/rules/sql.md требует +-- от каждого файла в data/sql обёртки BEGIN/COMMIT. Плюс миграции `auth` по определению +-- выполняются уже ВНУТРИ БД `auth` — то есть создать её собой они не могут. Отсюда +-- отдельный bootstrap-шаг, по образцу scripts/bootstrap_glitchtip.sh (там так же +-- заводится вторая БД на этом же сервере). +-- +-- ПОЧЕМУ \gexec, А НЕ DO-БЛОК: +-- DO-блок — это функция, она выполняется внутри транзакции, значит CREATE DATABASE в ней +-- недопустим. \gexec строит текст команды на стороне клиента и отправляет её отдельным +-- стейтментом. Если WHERE NOT EXISTS отфильтровал строку, \gexec не получает ничего и +-- молча ничего не делает — это и даёт идемпотентность без ошибки на повторном прогоне. +-- ON_ERROR_STOP=on распространяется и на команды, выполненные через \gexec. +-- +-- ВЛАДЕЛЕЦ БД — $POSTGRES_USER (суперюзер кластера), НЕ auth_app. Владелец объекта имеет на +-- него все права в обход GRANT'ов; если бы БД и таблицы принадлежали прикладной роли, +-- точечные гранты в data/sql/auth/002_auth_app_role.sql были бы декорацией. Роль auth_app +-- создаётся миграцией 002 и получает только нужные DML-права. +-- +-- TEMPLATE template0 — сознательно, а не template1 (шаблон по умолчанию): template0 +-- гарантированно пуст и неизменяем, а в template1 любой может доустановить расширения или +-- объекты, и они молча окажутся в хранилище паролей. На образе postgis:16-3.4 сегодня +-- postgis лежит в template_postgis, а template1 чист (проверено локально на том же образе), +-- но полагаться на это как на инвариант незачем — template0 снимает вопрос навсегда. +-- ENCODING 'UTF8' указан явно (кластер и так UTF8 — вся кириллица gendesign лежит в нём), +-- чтобы кодировка хранилища логинов не зависела от того, с какими аргументами когда-нибудь +-- пересоздадут кластер. +-- +-- Пароля в этом файле нет и быть не может: роль создаётся passwordless в миграции 002, +-- пароль ставится отдельным шагом из env (ops/db-bootstrap/set_auth_app_password.sql). + +SELECT 'CREATE DATABASE auth TEMPLATE template0 ENCODING ''UTF8'';' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'auth') +\gexec + +-- Единственная преграда для «любая login-роль кластера (glitchtip, tradein_fdw_reader, +-- gendesign_reader) открывает сессию в хранилище паролей»: по умолчанию PostgreSQL выдаёт +-- CONNECT роли PUBLIC при создании БД. +-- +-- ДУБЛЬ С data/sql/auth/002_auth_app_role.sql — НАМЕРЕННЫЙ, не копипаста. Инвариант держится +-- в двух местах, потому что у файлов разный жизненный цикл: +-- * здесь (bootstrap) — ради ПЕРЕПРИМЕНЯЕМОСТИ: этот файл гоняется на КАЖДОМ деплое, там же, +-- где создаётся БД. Если `auth` восстановят из дампа или пересоздадут в обход миграций, +-- база появится с дефолтным PUBLIC-CONNECT, а 002 уже числится применённой в +-- _schema_migrations и второй раз не выполнится — REVOKE молча не вернётся. +-- * в 002 — ради САМОДОСТАТОЧНОСТИ миграции: применённая на пустую БД (scratch/staging, +-- ручной psql -f) она обязана давать полный периметр прав без чтения bootstrap-файлов. +-- Удалять любую из двух копий нельзя: каждая закрывает сценарий, который другая не покрывает. +-- +-- Выполнимо из подключения к БД `postgres` (мы именно в ней): права на объект DATABASE живут +-- в pg_database.datacl — это общий на кластер каталог, не локальный для БД, в отличие от +-- грантов на таблицы/схемы. Проверено эмпирически на postgis:16-3.4 (REVOKE из сессии в +-- `postgres` по другой БД убирает `=Tc/` из datacl, has_database_privilege('public', …, +-- 'CONNECT') → false). Команда идемпотентна — повторный прогон бесплатен. +REVOKE ALL ON DATABASE auth FROM PUBLIC; + +COMMENT ON DATABASE auth IS + 'Единое хранилище доступов: «Мера» (trade-in) и «Птица» (Site Finder). Схема — ' + 'data/sql/auth/*.sql, применяется отдельным циклом миграций в .forgejo/workflows/deploy.yml ' + '(таблица _schema_migrations живёт внутри этой же БД).'; diff --git a/ops/db-bootstrap/set_auth_app_password.sql b/ops/db-bootstrap/set_auth_app_password.sql new file mode 100644 index 00000000..bd6df39b --- /dev/null +++ b/ops/db-bootstrap/set_auth_app_password.sql @@ -0,0 +1,50 @@ +-- Set auth_app password from env. +-- Applied by .forgejo/workflows/deploy.yml after auth DB migrations: +-- psql -v pw="$AUTH_DB_PASSWORD" < ops/db-bootstrap/set_auth_app_password.sql +-- Источник переменной: AUTH_DB_PASSWORD из /opt/gendesign/backend/.env.runtime (chmod 600, +-- вне git). Зеркало паттерна ops/db-bootstrap/set_tradein_fdw_password.sql и +-- tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql. +-- +-- Idempotent: ALTER если роль существует, NOTICE и продолжает если нет (миграция +-- data/sql/auth/002_auth_app_role.sql могла ещё не примениться на первом деплое). +-- Пароль НИКОГДА не хранится в этом файле или в git — только имя переменной. +-- +-- Format %L экранирует пароль как SQL string literal — безопасно даже с кавычками. +-- +-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted блока ($$...$$) +-- — это правило psql, не bug. Поэтому password передаём в DO через сессионный GUC +-- (set_config), который psql интерполирует ВНЕ dollar quote, и читаем внутри через +-- current_setting(). По той же причине файл подаётся через stdin, а НЕ через `psql -c`. +-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на +-- "syntax error at or near ':'" именно на этом. +-- +-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное значение. Без +-- `\o /dev/null` psql напечатал бы пароль на stdout → leak в Forgejo Actions deploy logs +-- (retained, visible всем с repo read access). Поэтому оба set_config обёрнуты в +-- `\o /dev/null` / `\o` — глушится только их вывод, NOTICE из DO block (сигнал +-- идемпотентности) остаётся видимым. +-- +-- Rollback path: НЕ revert этого файла (вернёт сломанный :'pw' внутри $$). Корректный +-- rollback — unset AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime на VPS, deploy.yml +-- тогда пропустит этот шаг полностью (роль останется без пароля = логин по паролю невозможен). + +\o /dev/null +SELECT set_config('app.auth_pw', :'pw', false); +\o + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN + EXECUTE format('ALTER ROLE auth_app WITH PASSWORD %L', current_setting('app.auth_pw')); + RAISE NOTICE 'auth_app password set'; + ELSE + RAISE NOTICE 'auth_app role missing — migration data/sql/auth/002_auth_app_role.sql not applied yet'; + END IF; +END $$; + +-- Clear GUC after use (defense-in-depth — не оставляем password в session state даже на +-- short connection). Same \o trick — set_config return value is empty string here, но лишний +-- row в stdout всё равно не нужен. +\o /dev/null +SELECT set_config('app.auth_pw', '', false); +\o