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"
|
- ".forgejo/workflows/deploy.yml"
|
||||||
- "data/sql/**"
|
- "data/sql/**"
|
||||||
- "ops/glitchtip-auth-forwarder/**"
|
- "ops/glitchtip-auth-forwarder/**"
|
||||||
|
# Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом
|
||||||
|
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
|
||||||
|
# до прода до следующего чужого коммита в backend/.
|
||||||
|
- "ops/db-bootstrap/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|
@ -320,6 +324,70 @@ jobs:
|
||||||
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
|
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
|
||||||
fi
|
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).
|
# Build local-only sidecar images (glitchtip-auth-forwarder).
|
||||||
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
|
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
|
||||||
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.
|
# 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 |
|
| **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) |
|
| **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/.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 стек |
|
| **`/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 |
|
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
|
||||||
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
|
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
|
||||||
|
|
@ -62,6 +62,7 @@
|
||||||
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
|
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
|
||||||
| `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** |
|
| `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** |
|
| `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** |
|
| `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_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
|
||||||
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |
|
| `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