у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
@@ -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 к необратимому и требует спрашивать человека всегда —
|
||||
здесь и спрашивается.
|
||||
Reference in New Issue
Block a user