gendesign/data/sql/auth/001_identity_schema.sql
bot-backend bea61f6cd9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m0s
CI / backend-tests (pull_request) Successful in 15m35s
feat(auth): отдельная БД auth — фундамент единого входа «Меры» и «Птицы»
PR-1 эпика: вся авторизация переезжает на одну нейтральную форму входа, браузерный
popup (Caddy basic_auth) убирается. Этот PR — ТОЛЬКО фундамент, прод работает как
сейчас: в БД gendesign ничего не меняется, новая БД создаётся и наполняется
логинами без паролей, читать её пока некому.

Почему отдельная БД, а не таблица в существующей: хранилище доступов не должно
принадлежать продукту, из которого аккаунты выносятся. Сервер — существующий
gendesign-postgres (новый контейнер не заводим); проверено, что оба бэкенда
сидят в сети gendesign_shared и TCP-достают до него.

Состав:
- data/sql/auth/001-003 — схема (users, sessions), роль приложения, сид 13 логинов.
  password_hash = NULL у ВСЕХ: plaintext и bcrypt-хеши в git запрещены, пароли
  проставляются отдельно на проде (конвенция репы, прецедент tradein м.193).
- ops/db-bootstrap/create_auth_db.sql — CREATE DATABASE через \gexec. Не миграцией:
  CREATE DATABASE запрещён в транзакции, а миграции обязаны быть транзакционными.
- ops/db-bootstrap/set_auth_app_password.sql — пароль роли из env, зеркало
  set_tradein_fdw_password.sql (GUC + \o /dev/null + %L, строго через stdin —
  :'pw' не интерполируется внутри $$...$$, на этом падал деплой 2026-05-24).

Схема лежит в ПОДКАТАЛОГЕ data/sql/auth/ намеренно: основной цикл деплоя использует
`ls -1 data/sql/*.sql`, который в подкаталоги не рекурсирует → эти файлы физически
не могут примениться в БД gendesign. Защита не на дисциплине, а на глобе. Триггер
`data/sql/**` подкаталог при этом покрывает.

Права: владелец БД — суперюзер, а не auth_app (иначе гранты были бы декорацией).
users — только SELECT+UPDATE (INSERT не выдан: создания аккаунтов в этом PR нет, а
снять грант, на который уже опирается прод-код, сложнее чем выдать). REVOKE ALL ON
DATABASE FROM PUBLIC продублирован в bootstrap и в миграции намеренно: bootstrap
гоняется каждый деплой (переприменяемость), миграция — однократно (самодостаточность).

Проверено ИСПОЛНЕНИЕМ на postgis/postgis:16-3.4 (тот же образ, что на проде):
двойной прогон всех файлов идемпотентен; COALESCE-защита сида не затирает вручную
проставленные пароль/имя (проверено живьём); ASCII-CHECK отклоняет кириллицу;
auth_app коннектится, посторонняя роль → permission denied; битая миграция даёт
exit 1 и НЕ пишется в _schema_migrations, т.е. деплой прервётся до подъёма кода;
пароль с кавычками и бэкслешем не ломает %L и не печатается в stdout.

Тест backend/tests/sql/test_auth_sql_migrations.py: имена, транзакционность, наличие
wiring в deploy.yml и детектор паролей/хешей, покрывающий И data/sql/auth, И
ops/db-bootstrap — единственное место в репе с ALTER ROLE ... PASSWORD.
Детектор проверен на живучесть: подложенный bcrypt-хеш роняет тест.

Открытые развилки зафиксированы комментариями в коде, решаются в PR-2/3:
судьба tradein_users/tradein_sessions (два одинаковых по схеме хранилища) и
expired != disabled (trial-экран не выражается булевым is_active).

NB: переменную AUTH_DB_PASSWORD нужно завести вручную в runtime-env бэкенда на VPS.
Пока пусто — шаг ALTER ROLE пропускается с warning'ом, деплой не падает.
2026-07-31 21:47:33 +03:00

123 lines
9.9 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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