у записи появился владелец: чужую больше не отдают

- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
av
2026-08-14 12:18:11 +03:00
parent b7d4660aef
commit 8af8ec2e54
46 changed files with 2520 additions and 101 deletions
@@ -0,0 +1,197 @@
## Context
Сессия сегодня отвечает на один вопрос — узнан ли пришедший. На вопрос «чьё он
смотрит» не отвечает никто: опрос готовности отдаёт задачу всякому вошедшему по
её идентификатору. Так записано и в спеках прямым текстом — обе строки
(`access`, преамбула; `intake`, «Опрос готовности задачи») обещают, что сужение
придёт отдельной задачей. Эта задача та самая.
Учётные записи в сервисе уже есть: их заводит вход через внешнего провайдера,
заведённый 2026-08-12. Значит владельцем записи есть кому быть.
Ограничения, из которых собрано решение:
- **Данные не переносим** — рамки задачи объявляют чистый лист. Значит шагу схемы
не нужно ни умолчание для старых записей, ни правило их раздачи.
- **Применённый шаг схемы не переписывается** (инвариант, critical). Колонка
приезжает новым шагом.
- **Колонки очереди правятся в четырёх местах** пакета хранилища (инвариант,
major), и компилятор видит два из них.
- **Записи из Telegram владельца получить не могут**: связи чата с учётной
записью не существует, её заводит `telegram-account-link` следующей задачей.
## Goals / Non-Goals
**Goals:**
- у задачи расшифровки есть владелец, назначенный при приёме и неизменяемый;
- чужая задача по её идентификатору неотличима от несуществующей;
- конвейер работает по всем записям подряд, владельцем не сужаясь;
- колонка владельца не имеет умолчания: запись не достаётся никому по недосмотру.
**Non-Goals:**
- совместный доступ, роли, передача записи другому;
- перенос записей, заведённых до этого шага;
- владелец у записи, пришедшей ботом, — его назначает следующая задача;
- список своих записей и удаление: они стоят **на** владельце, но заводятся
своими задачами.
## Decisions
### Сужение живёт в хранилище, а не в обработчике
Читающий метод репозитория задач принимает владельца и отдаёт задачу, только
если она его. Не «прочитать и сравнить в обработчике»: сравнение, стоящее в
вызывающем, повторяется в каждом новом вызывающем, и первый же забывший его
открывает чужую запись. Компилятор при смене подписи метода приводит всех
вызывающих сам.
**Отвергнуто: правило доступа коллекции хранилища.** Коллекции хранилища знают
правила вида «владелец записи равен предъявителю», и в панели они работают. Наш
опрос готовности идёт мимо коллекций — своим обработчиком и своим запросом, — и
правило коллекции на этом пути не срабатывает вовсе. Опереться на него значит
получить защиту, которой не существует, и не заметить этого: тест, ходящий
нашим API, был бы зелёным в обоих случаях.
**Отвергнуто: второй метод чтения рядом с прежним** (`GetByID` и
`GetOwnedByID`). Это второй способ делать одно и то же, и выбирается он по
внимательности вызывающего. Метод остаётся один, и владелец у него обязателен.
### Чужая запись отвечает «не найдено», а не «доступ запрещён»
Отказ по чужой записи и отказ по несуществующей — один и тот же ответ, `404` с
тем же телом. Отдельный код на чужую запись превращает опрос в перебор: по нему
считывается, какие идентификаторы заведены, а идентификатор задачи и есть то,
что мы прячем.
Различать их в журнале при этом можно и нужно — журнал читает владелец сервиса,
а не тот, кто перебирает.
### Владелец — связь на учётную запись, а не строка
Колонка заводится связью с коллекцией пользователей: хранилище само следит за
тем, чтобы владельцем стояла существующая учётная запись, а не строка, похожая
на её идентификатор.
**Целостности при удалении учётной записи связь при этом не удерживает, и
прежнее обоснование этого решения было неверным.** Проверено по исходникам
`pocketbase@v0.39.10`, `core/record_model.go`, `deleteRefRecords`: при выключенном
каскадном удалении хранилище **вынимает** идентификатор из поля связи и сохраняет
запись без проверок. То есть удаление учётной записи не уносит её задачи — но
делает их ничьими, а по правилу этой же задачи ничья запись не достаётся по API
никому. Архив человека становится недостижим молча, и восстановить владельца
нечем.
Строка с идентификатором вела бы себя здесь лучше — она пережила бы удаление, —
но платила бы тем, что владельцем становится любое значение. Что делать с этим,
решает человек на чекпоинте: развилка вынесена в открытые вопросы.
### Захват читает колонку владельца
Владелец добавляется во все четыре места правки колонок очереди, включая
`acquireColumns` и `acquiredRow`, хотя конвейер владельцем не пользуется.
Причина в инварианте: колонка, забытая в этой паре, приезжает из захвата нулевой.
Сохранение конвейера сегодня кладёт только свои поля и владельца не трогает —
значит потери нет; но снимок задачи, в котором владелец всегда пуст, это ловушка
для первого же шага, который начнёт сохранять задачу целиком. Чтение колонки
стоит одной строки в каждом из четырёх мест и снимает ловушку насовсем.
### Приём назначает владельца из предъявленной сессии
Приём по HTTP уже требует сессию и отказывает `401` без неё. Владелец берётся
оттуда же, и другого источника у него нет: параметром запроса владелец не
задаётся никогда, иначе всякий вошедший заводит запись на чужое имя.
## Risks / Trade-offs
- **Колонка обязательна в схеме → приём из Telegram перестаёт вставлять записи.**
Критерий приёмки требует обязательности, рамки запрещают назначать владельца
записям бота, и вместе это ломает основной сегодня вход. → Развилка вынесена в
открытые вопросы и на чекпоинт; ниже разобраны три способа с ценой каждого.
- **Записи бота остаются без владельца и потому невидимы в API.** Это желаемое
поведение (ответ бот отдаёт сам, в чат), но оно значит, что после
`telegram-account-link` записи придётся связать с учётной записью задним
числом — а рамки объявляют владельца неизменяемым. → Граница названа здесь и
передаётся той задаче: неизменяемость относится к записи, у которой владелец
**есть**.
- **Тест на две сессии дороже прежних.** Нужны две учётные записи в тестовой
базе и две куки. → Заводятся тем же способом, что и в тестах входа.
- **Публичный контракт меняется**: `GET /api/status/{id}` на чужую задачу
отвечает `404` там, где отдавал содержимое. → Ломка намеренная и объявлена в
предложении; форма ответа при этом не трогается.
## Migration Plan
Один новый шаг схемы, добавляющий колонку в таблицу задач. Прежние шаги не
трогаются. Данные не переносятся: рамки объявляют чистый лист, и записей,
заведённых до шага, в боевой базе на момент выкладки быть не должно — это
проверяет владелец перед выкладкой, машина такого проверить не может.
**Отката приложения нет — решение владельца от 2026-08-14.** Прежний образ поверх
новой схемы поднялся бы: колонку он не читает, и она ему не мешает. Но и не
пишет — записи, принятые по HTTP в таком окне, остались бы без владельца и после
возврата вперёд не достались бы создателю уже никогда, молча. Способ выхода из
беды поэтому другой: восстановить базу из резервной копии либо выпустить новый
релиз вперёд.
## Open Questions
**Обязательна ли колонка владельца в схеме?** Решает человек на чекпоинте.
Критерий приёмки задачи требует «обязательна и без умолчания», рамки той же
задачи запрещают назначать владельца записям бота. Три способа:
1. **Колонка не обязательна в схеме, обязательность держит приём по HTTP.**
Запись из веба без владельца завести нельзя — отказывает приём; запись бота
лежит без владельца и по API невидима никому. Цена: «обязательна» держит код,
а не схема, и прямое обращение к базе мимо приёма может завести запись без
владельца. Критерий приёмки выполняется наполовину и требует переформулировки.
**Рекомендую этот.**
2. **Колонка обязательна, записи бота получают служебную учётную запись.**
Схема держит обязательность целиком. Цена втрое больше, чем казалось:
учётная запись, за которой не стоит человек, делает сервис источником учётных
записей — а паспорт объявляет управление ими вне цели; завести её штатным
путём нельзя вовсе, потому что создание записи разрешено только контексту
обмена OIDC, и понадобится ещё один шаг схемы; после
`telegram-account-link` записи придётся переназначать, нарушая неизменяемость
владельца в первой же следующей задаче. **Способ рекомендую снять с выбора.**
3. **Колонка обязательна, связь чата с учётной записью делается здесь же.**
Критерий выполняется буквально. Цена: задача вбирает в себя следующую и
вырастает примерно вдвое — а `telegram-account-link` заведена отдельной
строкой беклога намеренно.
**Сужается ли по владельцу сам файл записи?** Нашли независимо все три прохода
ревью дизайна, и это крупнее прочего. Разграничение, спроектированное выше,
закрывает опрос состояния — то есть метаданные задачи, — а само аудио лежит в
другой коллекции, у которой владельца нет и по этому плану не появится. Правило
просмотра там пускает всякого вошедшего, и знание идентификатора файловой записи
по-прежнему равно праву скачать чужую запись. Запись задачи между тем называет
затронутой «таблицу задач **и таблицу файлов**», а модель угроз обещает владельца
у задачи и файла именно этой задачей.
Дороже всего не сама дыра, а отметка о закрытии: после мерджа паспорт и модель
угроз перепишут разграничение как сделанное, и открытый путь перестанет быть
виден. Способы:
1. **Владелец и у файла, тем же шагом схемы**, правило просмотра сужается им.
Задача растёт на колонку, правило и свой тест. Полное закрытие.
2. **Обратная ссылка с файла на задачу**, владелец выводится через неё. Дешевле в
схеме, дороже в правиле; вдобавок ссылка на файл в задаче **переставляется**
каждым шагом конвейера, и исходная копия после конвертации не связана ни с чем.
3. **Оставить как есть и записать границу честно** — строкой Non-Goals с адресом
задачи-преемника, и не переписывать паспорт с моделью угроз словом «закрыто».
Дешевле всего сегодня, но разграничение остаётся половинчатым.
**Что делать с записями удалённого пользователя?** Связь при выключенном каскаде
снимает ссылку — записи остаются, но становятся ничьими и недостижимыми по API
навсегда. Способы: запретить удаление учётной записи, пока у неё есть записи;
держать рядом со связью неизменяемый снимок идентификатора; признать потерю
ценой и записать её. Сегодня удаления пользователей в сервисе нет вовсе, так что
третий способ ничего не ломает сейчас — но он же превращает архив в то, что
теряется одной кнопкой в панели.
**Подтверждение ломки публичного контракта.** `GET /api/status/{id}` на чужую
задачу начнёт отвечать `404` там, где отдавал содержимое. Проект относит
публичный контракт HTTP API к необратимому и требует спрашивать человека всегда —
здесь и спрашивается.