feat(auth): отдельная БД auth — фундамент единого входа «Меры» и «Птицы» [PR-1/6] #2597

Merged
lekss361 merged 1 commit from feat/auth-db-foundation into main 2026-07-31 19:06:42 +00:00
8 changed files with 663 additions and 1 deletions

View file

@ -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 если файлы не менялись.

View file

@ -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."

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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** |

View file

@ -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 живёт внутри этой же БД).';

View file

@ -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