- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
18 KiB
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
Обязательна ли колонка владельца в схеме? Решает человек на чекпоинте. Критерий приёмки задачи требует «обязательна и без умолчания», рамки той же задачи запрещают назначать владельца записям бота. Три способа:
- Колонка не обязательна в схеме, обязательность держит приём по HTTP. Запись из веба без владельца завести нельзя — отказывает приём; запись бота лежит без владельца и по API невидима никому. Цена: «обязательна» держит код, а не схема, и прямое обращение к базе мимо приёма может завести запись без владельца. Критерий приёмки выполняется наполовину и требует переформулировки. Рекомендую этот.
- Колонка обязательна, записи бота получают служебную учётную запись.
Схема держит обязательность целиком. Цена втрое больше, чем казалось:
учётная запись, за которой не стоит человек, делает сервис источником учётных
записей — а паспорт объявляет управление ими вне цели; завести её штатным
путём нельзя вовсе, потому что создание записи разрешено только контексту
обмена OIDC, и понадобится ещё один шаг схемы; после
telegram-account-linkзаписи придётся переназначать, нарушая неизменяемость владельца в первой же следующей задаче. Способ рекомендую снять с выбора. - Колонка обязательна, связь чата с учётной записью делается здесь же.
Критерий выполняется буквально. Цена: задача вбирает в себя следующую и
вырастает примерно вдвое — а
telegram-account-linkзаведена отдельной строкой беклога намеренно.
Сужается ли по владельцу сам файл записи? Нашли независимо все три прохода ревью дизайна, и это крупнее прочего. Разграничение, спроектированное выше, закрывает опрос состояния — то есть метаданные задачи, — а само аудио лежит в другой коллекции, у которой владельца нет и по этому плану не появится. Правило просмотра там пускает всякого вошедшего, и знание идентификатора файловой записи по-прежнему равно праву скачать чужую запись. Запись задачи между тем называет затронутой «таблицу задач и таблицу файлов», а модель угроз обещает владельца у задачи и файла именно этой задачей.
Дороже всего не сама дыра, а отметка о закрытии: после мерджа паспорт и модель угроз перепишут разграничение как сделанное, и открытый путь перестанет быть виден. Способы:
- Владелец и у файла, тем же шагом схемы, правило просмотра сужается им. Задача растёт на колонку, правило и свой тест. Полное закрытие.
- Обратная ссылка с файла на задачу, владелец выводится через неё. Дешевле в схеме, дороже в правиле; вдобавок ссылка на файл в задаче переставляется каждым шагом конвейера, и исходная копия после конвертации не связана ни с чем.
- Оставить как есть и записать границу честно — строкой Non-Goals с адресом задачи-преемника, и не переписывать паспорт с моделью угроз словом «закрыто». Дешевле всего сегодня, но разграничение остаётся половинчатым.
Что делать с записями удалённого пользователя? Связь при выключенном каскаде снимает ссылку — записи остаются, но становятся ничьими и недостижимыми по API навсегда. Способы: запретить удаление учётной записи, пока у неё есть записи; держать рядом со связью неизменяемый снимок идентификатора; признать потерю ценой и записать её. Сегодня удаления пользователей в сервисе нет вовсе, так что третий способ ничего не ломает сейчас — но он же превращает архив в то, что теряется одной кнопкой в панели.
Подтверждение ломки публичного контракта. GET /api/status/{id} на чужую
задачу начнёт отвечать 404 там, где отдавал содержимое. Проект относит
публичный контракт HTTP API к необратимому и требует спрашивать человека всегда —
здесь и спрашивается.