2026-09-14T12:07:29.253Z · coordinator[14.09 12:07Z координатор] WEB-078 — перенос владения объектами базы (SEC-068/069): приложение перестаёт быть хозяином базы
Обновление от 2026-09-14. Затронутые посадки: l115k (ac4eb8c68d70de829608bcae5a7acef693ab85fb, 13.09 17:27:44Z) — вскрыла дыру; l115m (806c45d22f6e2616ae8135caabfb0c99180729b4, 14.09 10:26:18Z) — после неё операция выполнена на боевой базе.
Карточка написана для человека, который открывает её впервые и не имеет ни журнала смены, ни переписки.
1. ЧТО БЫЛО СЛОМАНО
Приложение ходило в базу под старой ролью (`noteclone`), которая ВЛАДЕЛА всем — таблицами, самой базой, подпиской репликации. Это значит: если кто-то получит доступ через приложение, он получит не «доступ к данным», а право уронить схему целиком. Отдельные роли (`web` для приложения, `web_migrator` для миграций, `web_backup` для резервных копий, `web_owner` как владелец без права входа) были заведены заранее, но ВЛАДЕНИЕ объектами к ним так и не перешло.
В человеческом виде дефект проявился 13.09 сразу после посадки линии l115k: перестала сниматься резервная копия — `pg_dump` упал с `permission denied for table SourceTextChunk`. Три таблицы, созданные миграциями этой линии, принадлежали `web_migrator`, и у роли приложения `web` не было на них никаких прав.
2. КАК НАШЛИ И ПОЧЕМУ НЕ ПОЙМАЛИ РАНЬШЕ
Нашли СЛУЧАЙНО — посадкой. Не аудитом, не проверкой, не тестом.
Причина, почему не поймали раньше, названа прямо: `scripts/security/db-roles/check-applied.mjs` — единственный инструмент, который должен был это ловить, — ВООБЩЕ НЕ ПРОВЕРЯЛ ВЛАДЕНИЕ. Дыра жила месяцами, и ни один инструмент не мог её обнаружить; регресс тоже не поймал бы.
Дополнительно позже выяснилось, что и сам чекер после починки НИГДЕ НЕ ВЫЗЫВАЕТСЯ (см. 3.6).
3. ЭВОЛЮЦИЯ, ВКЛЮЧАЯ ТУПИКИ
Четыре круга приёмки. Каждая находка — на СЛОЙ ВЫШЕ предыдущей, и НИ ОДНУ не нашёл автор.
3.1. КРУГ 1. Автор (волна 3766) закрыл уровень ОБЪЕКТОВ: перенос владения всеми объектами схемы на `web_owner` одной атомарной транзакцией плюс `ALTER DEFAULT PRIVILEGES`. Доказал на одноразовой PG16: красное `must be owner` до, зелёное после, `prisma migrate deploy` под `web_migrator` проходит БЕЗ членства в `noteclone`, права роли `web` не изменились.
Приёмка 3778 вернула NO-GO, и этот отказ остановил операцию НА БОЕВОЙ БАЗЕ за час до запуска.
ТУПИК, ПРОТИВОПОЛОЖНЫЙ ЗАМЫСЛУ: `ops/sql/web078_ownership_transfer.sql` содержал БЕЗУСЛОВНЫЙ `ALTER DATABASE ... OWNER TO :'legacy_role'`. На форме базы, которую предполагал автор (старая роль владеет и базой, и таблицами), строка возвращает владельца на место — всё выглядит правильно. На форме, где старая роль владеет таблицами, но НЕ базой, ТА ЖЕ СТРОКА ВЫДАЁТ ЕЙ ВЛАДЕНИЕ БАЗОЙ, КОТОРОГО НЕ БЫЛО. Скрипт, написанный чтобы отобрать привилегию, на другой форме её выдаёт. Форму боевой базы никто заранее не зафиксировал.
Три блока защитных проверок использовали `\echo` + `\quit 2`, что при `ON_ERROR_STOP on` не даёт ненулевого кода: ОТКАЗ ЗАЩИТЫ ВЫГЛЯДЕЛ КАК УСПЕХ.
Порядок для прода оставлял приложение без прав: `web` не получал DML на таблицы, созданные МИГРАЦИЕЙ.
Утверждение автора «потребители, логинящиеся как старая роль, не пострадают» — неверно: роль теряет весь доступ к данным, а такие потребители в проекте есть.
ПОЧЕМУ ЗЕЛЁНЫЙ ТЕСТ АВТОРА НИЧЕГО НЕ ДОКАЗЫВАЛ: приёмка прогнала его целиком, он проходит с RC=0. Но он проверяет ТОЛЬКО ТУ ФОРМУ БАЗЫ, КОТОРУЮ ПРИДУМАЛ АВТОР. Зелёное означало «мой сценарий работает», а не «операция безопасна».
3.2. КРУГ 2. Волна 3796 закрыла четыре блокера. Приёмка 3800 — снова NO-GO, дефект НА СТУПЕНЬ ВЫШЕ: `REASSIGN OWNED BY` переносит владение ОБЩЕКЛАСТЕРНЫМИ объектами — всеми базами и табличными пространствами роли ВО ВСЁМ КЛАСТЕРЕ. Защита снимала и восстанавливала владельца ТОЛЬКО текущей базы, поэтому соседняя база молча переезжала.
ЖИВАЯ ПРОВЕРКА: после прогона `web_migrator` — обычная login-роль — успешно выполнил `DROP DATABASE` соседней базы, на которую скрипт вообще не наводили.
3.3. КРУГ 3. Волна 3803 закрыла кластерную область. Приёмка 3807 — NO-GO, ещё ступень выше: владеемых общекластерных классов не два, а ТРИ. Подписка (`pg_subscription`) в ДРУГОЙ базе того же кластера при обычном вызове молча переезжала на `web_owner`, скрипт выходил с кодом 0 и без предупреждений, после чего `web_migrator` мог сделать `ALTER SUBSCRIPTION ... DISABLE` и `DROP SUBSCRIPTION` в базе, на которую скрипт не наводили.
3.4. КРУГ 4. Волна 3814 закрыла подписки и ответила на главный вопрос: по системным каталогам PostgreSQL 16 владеемых общекластерных классов РОВНО ТРИ — `pg_database`, `pg_tablespace`, `pg_subscription`. Проверено дважды независимо на живом кластере. Цепочка «объект → база → кластер → подписки» ДОШЛА ДО КОНЦА: ступени выше не существует. Попутно волна нашла и закрыла ещё один дефект, которого никто не заказывал — гонку между снимком и переносом.
Приёмка 3820 подтвердила «ровно три» ЧЕТЫРЬМЯ независимыми методами и намеренно НЕ подключала харнессы прошлых приёмок, чтобы общая ошибка прошлых харнессов не спряталась. Нашла остаточный дефект в самой правке 3814: инвариант сверяет подписки по имени, а имена уникальны лишь в паре (база, имя). Поэтому `GO` дан УСЛОВНО — при условии, что на проде подписок нет.
3.5. УСЛОВИЕ НЕ ВЫПОЛНИЛОСЬ, И ДАЛЬШЕ БЫЛ ТУПИК, ОКАЗАВШИЙСЯ ПЕРЕВЁРНУТЫМ.
Предусловие прогнано на проде — файл НЕ пуст: `nc_move_sub | noteclone | noteclone`, `enabled=false`, `slot=nc_move_sub`. Подписка принадлежит старой роли и живёт в целевой базе, значит `REASSIGN OWNED BY` её тронет.
Дальше выяснилось, что подписка не мёртвая, а СПЯЩАЯ: `remote_lsn = 15/4704B4F0` при `local_lsn = 0/0`. Операция была остановлена из опасения, что после переноса владельцем станет `web_owner` без права входа и подписку станет некому разбудить.
ОПАСЕНИЕ ОКАЗАЛОСЬ ПЕРЕВЁРНУТЫМ. Волна 3829 воспроизвела форму на одноразовых кластерах PostgreSQL 16.14 и 17.10, шесть скриптов, все `_PASS`, к проду доступа не имела:
`local_lsn = 0/0` — НЕ потеря. Поле вообще не сохраняется на диск и после любого рестарта читается нулём. Позиция живёт в `remote_lsn`, и она цела. Ни одна из двух рабочих гипотез не подтвердилась.
Перенос НЕ ТРОГАЕТ origin ни в одном бите: `roident`, `roname`, `remote_lsn`, `local_lsn` совпадают до и после; подписка не пересоздаётся, OID не меняется.
`ALTER SUBSCRIPTION … ENABLE` требует ТОЛЬКО ВЛАДЕНИЯ. Сегодня владелец — `noteclone`, и он УМЕЕТ ЛОГИНИТЬСЯ: подписка просыпается по-настоящему, данные поедут — это воспроизведено. После переноса владельцем станет `web_owner` с `NOLOGIN`, и воркер НЕ СТАРТУЕТ ВООБЩЕ (`FATAL: role "web_owner" is not permitted to log in`) — проверено и на 16, и на 17.
Значит ПЕРЕНОС УМЕНЬШАЕТ РИСК, А НЕ УВЕЛИЧИВАЕТ. Остановка сама по себе была верной — предусловие «GO» действительно не выполнялось как написано, и разобраться стоило, — но обоснование остановки было противоположно правде.
3.6. ПАРАЛЛЕЛЬНЫЙ СЮЖЕТ: ЧЕКЕР, СДЕЛАННЫЙ ПРАВИЛЬНО И НЕ ПОДКЛЮЧЁННЫЙ НИКУДА.
Волна 3781 научила чекер смотреть на владение: на сломанной базе ДО правки он зелёный (`FAIL=0`, exit 0), ПОСЛЕ — красный и называет каждый объект по имени. Ту аварию, что жила месяцами, он ловит.
Приёмка 3797 вернула NO-GO с тремя блокерами:
Чекер НЕ ВЫЗЫВАЕТСЯ НИГДЕ — ни в CI, ни в скрипте посадки, ни в скрипте миграций, ни одним `pnpm`-скриптом. Даже два его собственных unit-теста не запускаются ничем. В этом отношении не изменилось НИЧЕГО.
Владение схемой `public` не проверяется вообще. Отдать `public` старой роли можно на «зелёной» базе; чекер молчит, а роль получает право на `DROP SCHEMA public CASCADE`.
ВЕРДИКТ ПО ВЛАДЕНИЮ БАЗОЙ БЫЛ ИНВЕРТИРОВАН относительно штатной починки: правильное состояние помечалось как `FAIL` (exit 1), а состояние, которое штатная починка ПРЯМО ЗАПРЕЩАЕТ, — как `PASS` (exit 0). Чекер АКТИВНО ТОЛКАЛ К НЕБЕЗОПАСНОЙ КОНФИГУРАЦИИ, и «чинили» бы по нему в обратную сторону, с зелёным отчётом.
Закрыто волной 3801, принято приёмкой 3812.
3.7. НАХОДКА ПОСЛЕДНЕЙ ПРИЁМКИ, СЕРЬЁЗНЕЕ ИСХОДНОГО ВОПРОСА.
Приёмка 3841 переставила порядок (засов ДО переноса, а не после — он транзакционный, ничего не стоит, не требует издателя и закрывает единственное окно, где подписка одновременно пробуждаема и принадлежит роли из ежедневного миграционного пути) и нашла своё:
НЕПУСТОЙ СПИСОК ТАБЛИЦ ПОДПИСКИ — ЭТО НЕ «СПИСОК ЕСТЬ», А «СПИСОК УСТАРЕЛ». Живых таблиц 219, зарегистрировано 195, НЕ ЗАРЕГИСТРИРОВАНО 24, «зарегистрировано, но исчезло» — 0.
Среди незарегистрированных — `SourceTextChunk`, `ErasureTombstone`, `EmbedShareLinkAudience` (принесены миграциями l115k) и `DiagnosticEvent`. Список отстал ровно на те таблицы, что мы сами добавили линиями.
Последствие измерено: apply-воркер работает с `session_replication_role = replica`, где ВНЕШНИЕ КЛЮЧИ НЕ СРАБАТЫВАЮТ. Если подписка когда-нибудь применит поток, 195 таблиц получат данные, а 24 — молча ничего; зафиксированная транзакция издателя ляжет на подписчика ПОЛОВИНОЙ, НАВСЕГДА, при нулях в счётчике ошибок и без единой строки в логе, оставив строку, нарушающую собственный внешний ключ.
Правило из восьми условий оказалось НЕПОЛНЫМ: списка таблиц в нём нет, а оператор с цифрой 195 получает ЛОЖНОЕ СПОКОЙСТВИЕ.
4. ЧТО СДЕЛАЛИ В ИТОГЕ
Операция выполнена НА БОЕВОЙ БАЗЕ 14.09, сразу после посадки l115m. Записи сделаны в момент событий.
ШАГ 0 — состояние снято заново, все двенадцать условий выполнены. Ключевое:
все 195 отношений в состоянии ready; живых 219 / зарегистрировано 195 / не зарегистрировано 24 / «зарегистрировано, но исчезло» 0; счётчики ошибок применения и синхронизации 0;
`conninfo` подписки указывает на `127.0.0.1:15432`, при этом `ss -ltnp | grep 15432` — пусто и сокет `/tmp/.s.PGSQL.15432` отсутствует. ИЗДАТЕЛЯ НЕТ. Пробуждение сегодня дало бы бесконечные попытки соединения, а не поток данных;
восемь условий предыдущего круга: подписка выключена, слот `nc_move_sub`, владелец `noteclone`, воркер пуст, публикаций 0, слоты только физические (`a2_standby`, `hetzbk_wal`), рабочих логической репликации 0, дублей имён нет.
Защита пакета сработала широко и правильно: нашла четыре `COPY` — ВНУТРИ строковых литералов, описывающих состояния подписки. Чистый `SELECT`.
ШАГ 1 — ЗАСОВ. Транзакционный скрипт с четырьмя предпроверками и двумя постпроверками с откатом:
ДО: slot_name = nc_move_sub remote_lsn = 15/4704B4F0 отношений 195
ПОСЛЕ: slot_name = <NULL> remote_lsn = 15/4704B4F0 отношений 195
Подписка больше не пробуждаема — слота нет. Позиция цела. Обратная команда выписана в шапке скрипта: `ALTER SUBSCRIPTION nc_move_sub SET (slot_name = 'nc_move_sub')`.
ШАГИ 2–5 — ПЕРЕНОС.
Скрипт взят из зеркала и сверен ПОБАЙТОВО: `ops/sql/web078_ownership_transfer.sql` — один и тот же git-объект `794e0670f3` в ветках 3814, 3829 и 3841, то есть исполнено ровно то, что прогоняла приёмка. 478 строк, sha256 `abfcb7573b…`.
ВАЖНО ДЛЯ ПОНИМАНИЯ: этот файл живёт в ветке `3814`, которая была СНЯТА из линии l115m. Это разовый операторский SQL, а не код прода. Проверять надо было не «посажен ли он», а «тот ли это файл, что принимала приёмка» — и это проверено сравнением git-объектов, а не предположением. В дереве l115m (`806c45d22f`) этого файла НЕТ; там `ops/sql/` содержит `web078_roles.sql`, `analytics_readonly_role.sql`, `web078_roles_test.sh`, `dbroles_repair_test.sh`, `web078_test_additive_migration.sql`.
Запуск: `-v legacy_role=noteclone`, `SET lock_timeout = 5000`, целиком одна транзакция. Вывод: 1132 объекта, `REASSIGN OWNED` → `ALTER DATABASE` → два `DO` → `GRANT` → `COMMIT`, RC=0.
ДО: подписка noteclone | таблиц у старой роли 216 | владелец базы noteclone
ПОСЛЕ: подписка web_owner | таблиц у старой роли 0 | владелец базы noteclone
ВЛАДЕЛЕЦ БАЗЫ ОСТАЛСЯ ПРЕЖНИМ НАМЕРЕННО, и это не упущение. `REASSIGN OWNED BY` не ограничивается текущей базой — он метёт каждый общекластерный объект роли: сестринские базы, табличные пространства, подписки. Скрипт снимает снимок до и ВОЗВРАЩАЕТ владельца базы обратно, иначе `web_owner` (а через постоянное членство — и `web_migrator`) получил бы права уровня `ALTER DATABASE`, `RENAME` и путь к `DROP DATABASE`. Область сужена до таблиц, последовательностей, представлений и функций; подписка перенесена отдельно и осознанно — ради неё всё и делалось.
ВЕРИФИКАЦИЯ, всё сошлось дословно:
объектов у старой роли в `public` — 0
владелец базы — `noteclone` (как и до, по замыслу)
`nc_move_sub owner=web_owner enabled=false slot=<NULL> rels=195 lsn=15/4704B4F0`
`web_owner rolcanlogin = f` — второй барьер на месте
рабочих логической репликации — 0
строка состояния подписки совпала с ожидаемой в отчёте приёмки ПОСИМВОЛЬНО.
Прод здоров сразу после переноса: оба порта `ready: True` на коммите `806c45d22f…`, `paid: True`, поза `enforce_ready`, отказов нет.
Отдельно, из посадки l115l: трём таблицам, созданным миграциями l115k (`SourceTextChunk`, `ErasureTombstone`, `EmbedShareLinkAudience`), выданы те же права, что на соседних таблицах, плюс `ALTER DEFAULT PRIVILEGES` для обеих создающих ролей, чтобы не повторялось. Запись внесена в `/etc/nc-db-roles/CHANGELOG.txt`.
Прогон `web078_roles.sql` после переноса, стоявший в списке дел как обязательный, СНЯТ: по разбору приёмки это лечение на случай, которого не произошло (`web_owner rolcanlogin = f` подтверждено).
5. ЧЕМ ДОКАЗАНО И ГРАНИЦЫ ЗАЯВЛЕНИЯ
ДОКАЗАНО:
Четыре круга приёмок, каждый — на одноразовых настоящих кластерах, с воспроизведением дефекта ДО и отказом ПОСЛЕ. В круге 2 дефект воспроизведён живой попыткой `DROP DATABASE` соседней базы обычной login-ролью.
Приёмка 3841 прогнала НАСТОЯЩИЙ `ops/sql/web078_ownership_transfer.sql` на своих кластерах, на НАШЕЙ форме (с непустым списком таблиц подписки): подписка переходит к `web_owner`, а `remote_lsn`, `local_lsn`, список из 195 таблиц, `slot_name`, `conninfo`, публикации и все флаги остаются БАЙТ В БАЙТ теми же. Воркер с NOLOGIN-владельцем не стартует на 16 и на 17.
Все 28 скриптов трёх прошлых приёмок зелёные на итоговом коде.
На проде: значения ДО и ПОСЛЕ приведены выше, строка состояния подписки совпала посимвольно.
Пост-QA линии l115k (волна 3777) доказал на настоящей PostgreSQL то, ради чего ставился гейт миграций: `contract`-миграция `web651` не может опустошить `Document.content`, пока её флаг выключен; обе `expand`-миграции обратимы без следа.
ГРАНИЦЫ — читать обязательно:
Доказано, что старая роль больше не владеет объектами в `public` и что прод здоров. НЕ ДОКАЗАНО, что ничто в хозяйстве не логинится под старой ролью: приёмка 3778 прямо сказала, что такие потребители в проекте есть и перечислены в документации, и потребовала предполётного сбора всех активных подключений с явным решением их судьбы.
Устаревший список таблиц подписки (24 незарегистрированные) ИЗМЕРЕН, но НЕ ИСПРАВЛЕН.
Владение САМОЙ БАЗОЙ осталось у старой роли намеренно — значит у неё сохраняется неявный `CREATE` на `public`. Это воспроизведено: старая роль всё ещё может создать таблицу. Это отдельное решение с другим радиусом, и оно не принято.
Автоматическая проверка владения в линии l115m ОТСУТСТВУЕТ: ветка чекера `3801` была снята из линии (см. ниже). До её посадки владение не проверяет ничто.
6. ЧТО ОСТАЛОСЬ ОТКРЫТЫМ
Пара веток `3801` (гейт владения в инструменте посадки) + `3814` (SQL переноса) СНЯТА из l115m сознательно, и это надо понимать точно. `3801` встраивает в `ops/line-factory/migrations.py` БЕЗУСЛОВНЫЙ гейт: пока есть дрейф владения, инструмент посадки отказывается трогать БД и в режиме проверки, и в режиме применения; обхода нет. Условие плана сборки было «перенос владения применён к кластеру ДО посадки» — на момент сборки l115m оно не выполнялось, и посадив `3801`, мы поставили бы на прод инструмент посадки, который откажется работать с базой: следующая посадка встала бы на собственном гейте.
Теперь условие ВЫПОЛНЕНО, перенос сделан. Пара `3801`+`3814` может идти в следующую линию — блокер снят.
Проверка, что снятие пары было чистым откатом, а не полумерой, сделана фактом: дерево после первых шести слияний БАЙТ-В-БАЙТ одинаково с парой и без неё; пересечение файлов пары с остальными семью — 0; набор тестов фабрики линии без пары зелёный 149/149, оба падающих теста вносит сама `3801`.
Устаревший список таблиц подписки (24 незарегистрированные) — отдельная задача.
Судьба потребителей, логинящихся под старой ролью.
Владение схемой `public` и самой базой — отдельное решение.
7. TROUBLESHOOTER — ЧТО ДЕЛАТЬ И ЧЕГО НЕ ДЕЛАТЬ
ЧЕГО НЕ ДЕЛАТЬ (записано из отчёта приёмки, чтобы не потерялось):
НЕ выполнять `DROP SUBSCRIPTION`. Это единственная необратимая команда во всей теме: origin уничтожается вместе с подпиской, а восстановление позиции требует ДВУХ рестартов боевого кластера и живого слота у издателя, которого нет.
НЕ снимать засов «чтобы посмотреть».
НЕ выдавать `web_owner` право `LOGIN`. Если случится — прогнать `ops/sql/web078_roles.sql`: он вернёт `NOLOGIN` сам и подписку не тронет.
НИКОГДА не выполнять голый `REASSIGN OWNED BY` руками, как напрашивается. На PostgreSQL 16 он переназначает владение САМОЙ БАЗОЙ, если исходная роль ею владеет, и метёт общекластерные объекты — соседние базы, табличные пространства и подписки. Поставляемый скрипт от этого защищён, ручная команда — нет.
ЕСЛИ ПОСЛЕ ПОСАДКИ СЛОМАЛАСЬ РЕЗЕРВНАЯ КОПИЯ (`pg_dump: permission denied for table X`):
Миграция создала таблицу X под `web_migrator`, и у роли `web` нет на неё прав. Выдать те же права, что на соседних таблицах, и проверить `ALTER DEFAULT PRIVILEGES` для ОБЕИХ создающих ролей. Запись обязательно внести в `/etc/nc-db-roles/CHANGELOG.txt`.
ЕСЛИ НУЖНО ПОВТОРИТЬ ПЕРЕНОС ИЛИ ПРОВЕРИТЬ ЕГО:
Взять файл из зеркала и СВЕРИТЬ git-объект с тем, что прогоняла приёмка, а не брать «похожий». Правильный объект — `794e0670f3`.
Порядок: засов (`slot_name = NONE`) → перенос → верификация. Засов СНАЧАЛА, не после.
Все шаги обратимы; необратимых в плане нет.
ЕСЛИ ЗЕЛЁНЫЙ ТЕСТ ГОВОРИТ, ЧТО ВСЁ ХОРОШО:
Спросить, на КАКОЙ ФОРМЕ базы он это говорит. Трижды за смену зелёное означало «мой сценарий работает», а не «инструмент защищает». Проверять надо состояние, которого автор НЕ ЖДАЛ.
Проверить отдельно: видит ли инструмент владельца САМОЙ базы и схемы `public`; и не инвертирован ли его вердикт (правильное состояние он обязан помечать как проходящее, запрещённое — как отказ).