feat(auth): отдельная БД auth — фундамент единого входа «Меры» и «Птицы» [PR-1/6] #2597
8 changed files with 663 additions and 1 deletions
|
|
@ -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 если файлы не менялись.
|
||||
|
|
|
|||
160
backend/tests/sql/test_auth_sql_migrations.py
Normal file
160
backend/tests/sql/test_auth_sql_migrations.py
Normal 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."
|
||||
123
data/sql/auth/001_identity_schema.sql
Normal file
123
data/sql/auth/001_identity_schema.sql
Normal 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;
|
||||
82
data/sql/auth/002_auth_app_role.sql
Normal file
82
data/sql/auth/002_auth_app_role.sql
Normal 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;
|
||||
111
data/sql/auth/003_users_seed.sql
Normal file
111
data/sql/auth/003_users_seed.sql
Normal 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;
|
||||
|
|
@ -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** |
|
||||
|
|
|
|||
67
ops/db-bootstrap/create_auth_db.sql
Normal file
67
ops/db-bootstrap/create_auth_db.sql
Normal 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 живёт внутри этой же БД).';
|
||||
50
ops/db-bootstrap/set_auth_app_password.sql
Normal file
50
ops/db-bootstrap/set_auth_app_password.sql
Normal 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
|
||||
Loading…
Add table
Reference in a new issue