у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# ADR-2026-08-14. Учётная запись с записями не удаляется, и это осознанный тупик
|
||||
|
||||
- **Дата:** 2026-08-14
|
||||
- **Источник:** [openspec/changes/archive/2026-08-14-record-ownership/design.md](../../openspec/changes/archive/2026-08-14-record-ownership/design.md), раздел `Open Questions`
|
||||
|
||||
## Решение
|
||||
|
||||
Удаление учётной записи, у которой остались задачи расшифровки либо файлы,
|
||||
отвергается — отказом с названной причиной. Способа удалить записи в сервисе нет
|
||||
вовсе, поэтому до задачи про удаление записи такая учётная запись не удаляется
|
||||
никак: ни владельцем панели, ни самим человеком.
|
||||
|
||||
Решение принято человеком на чекпоинте задачи `record-ownership` из трёх
|
||||
предложенных способов.
|
||||
|
||||
Дословно из источника:
|
||||
|
||||
> **Что делать с записями удалённого пользователя?** Связь при выключенном
|
||||
> каскаде снимает ссылку — записи остаются, но становятся ничьими и
|
||||
> недостижимыми по API навсегда. Способы: запретить удаление учётной записи, пока
|
||||
> у неё есть записи; держать рядом со связью неизменяемый снимок идентификатора;
|
||||
> признать потерю ценой и записать её.
|
||||
|
||||
## Почему
|
||||
|
||||
Колонка владельца — связь с учётной записью, и каскадное удаление у неё
|
||||
выключено: сервис объявлен архивом и молча удалить чужой архив не вправе. Одного
|
||||
этого мало, и проверка по исходникам `pocketbase@v0.39.10` показала почему: при
|
||||
выключенном каскаде хранилище **вынимает** идентификатор из поля связи и
|
||||
сохраняет запись без проверок. Задачи остались бы на месте, но стали бы ничьими —
|
||||
а ничья запись по правилу той же задачи не достаётся по API никому. Архив
|
||||
человека исчезал бы молча, и восстановить владельца было бы нечем: прежнего
|
||||
значения не остаётся нигде.
|
||||
|
||||
Прежнее обоснование выбора связи вместо строки — «связь удержит целостность» —
|
||||
было неверным, и это выяснилось на ревью дизайна.
|
||||
|
||||
## Чем платим
|
||||
|
||||
Владелец панели упирается в отказ, а выхода из него сегодня нет: удаление записи
|
||||
приносит отдельная задача. Тупик назван прямо, а не обнаружен потом.
|
||||
|
||||
Отказ обязан доезжать до спрашивающего: хранилище пропускает наружу только свою
|
||||
ошибку роутера, а всякую другую подменяет сообщением про обязательную связь.
|
||||
Подсказка эта ведущая — единственная обязательная связь у задачи это файл, — и
|
||||
владелец панели, поверив ей, пошёл бы удалять записи руками, то есть делать ровно
|
||||
то необратимое, ради предотвращения чего запрет и заведён. Это нашло ревью кода.
|
||||
|
||||
## Что рассматривалось и отвергнуто
|
||||
|
||||
- **Неизменяемый снимок идентификатора рядом со связью.** Пережил бы удаление, и
|
||||
запись можно было бы вернуть человеку. Отвергнуто: владельцем становится любая
|
||||
строка, и целостность, ради которой выбрана связь, теряется.
|
||||
- **Признать потерю ценой и записать её.** Дешевле всего сегодня — удаления
|
||||
пользователей в сервисе нет вовсе. Отвергнуто: архив, теряемый одной кнопкой в
|
||||
панели, противоречит решению от 2026-08-11 о том, что сервис — архив.
|
||||
|
||||
## Связанное
|
||||
|
||||
Запрет ставит сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||||
позвать, теряет защиту молча — и теряла, пока его добавляли отдельной строкой
|
||||
запуска. Норма — `openspec/specs/storage`, «Учётная запись с записями не
|
||||
удаляется».
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
|
||||
видит всё, что видел прежде аноним.
|
||||
2026-08-12. Здесь же разграничение записей по владельцу: запись из веба
|
||||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а запись
|
||||
из Telegram владельца не имеет и по API не достаётся никому. Задача
|
||||
`record-ownership` 2026-08-14.
|
||||
|
||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
||||
|
||||
+24
-2
@@ -45,6 +45,7 @@ Object Storage — каждая своей записью.
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `file` | file | Сам файл; пусто у копии в Object Storage |
|
||||
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
|
||||
| `location` | select | `local` или `s3` |
|
||||
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
@@ -60,6 +61,7 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
|
||||
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
|
||||
| `source` | select | `api`, `telegram`, `unknown` |
|
||||
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
||||
@@ -83,8 +85,28 @@ capability, и третий смысл развёл бы одно слово п
|
||||
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
|
||||
потребует нового шага.
|
||||
|
||||
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
|
||||
может только владелец панели. Проверено прогоном: анонимный запрос к
|
||||
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
||||
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
|
||||
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
|
||||
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит
|
||||
поэтому сам приём, а не схема.
|
||||
|
||||
Выборка по владельцу сужает **чтение задачи**: чужая, ничья и несуществующая
|
||||
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
|
||||
обрабатывает записи всех. Тот же шаг сужает правило просмотра коллекции
|
||||
`files` владельцем: прежнее правило пускало всякого вошедшего, и знание
|
||||
идентификатора файловой записи равнялось праву скачать чужое аудио.
|
||||
|
||||
**Учётная запись с задачами не удаляется.** Каскадное удаление у связи выключено,
|
||||
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
|
||||
сохраняет запись без проверок — задачи остались бы, но стали бы ничьими, а ничья
|
||||
задача не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
|
||||
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
|
||||
судит. Цена названа прямо — владелец панели упирается в отказ, а удаления
|
||||
записей в сервисе пока нет вовсе.
|
||||
|
||||
**Правила доступа задач пусты**, то есть перечислять и читать их может только
|
||||
владелец панели. Проверено прогоном: анонимный запрос к
|
||||
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
|
||||
`/api/settings` и `/api/crons` — `401`.
|
||||
|
||||
|
||||
+6
-6
@@ -69,10 +69,10 @@
|
||||
файл. Своей записи и работы без сети не делаем.
|
||||
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
||||
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
|
||||
доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
|
||||
записи в модели данных не существует, и всякий вошедший видит все записи
|
||||
([security.md](security.md), «Периметр»). Это состояние, а не решение;
|
||||
закрывает его `record-ownership`.
|
||||
доступа к чужим записям целью нет, и с 2026-08-14 его нет и на деле: у записи
|
||||
есть владелец, и чужую по её идентификатору не отдают
|
||||
([security.md](security.md), «Периметр»). Закрыла это задача
|
||||
`record-ownership`.
|
||||
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
|
||||
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
|
||||
пользователя, потратившего слишком много, останавливает разговор или отзыв
|
||||
@@ -100,8 +100,8 @@
|
||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
||||
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
||||
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
||||
его заводит `api-tokens`, — как нет и разграничения записей между
|
||||
пользователями.
|
||||
его заводит `api-tokens`. Записи при этом разграничены: программа с чужой
|
||||
сессией видит только записи того, чью сессию предъявила.
|
||||
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
||||
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
||||
вышло, и предложение повторить.
|
||||
|
||||
@@ -186,3 +186,37 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
||||
способов:
|
||||
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
|
||||
копирование, которых у сервиса-архива нет никаких.
|
||||
|
||||
## Разграничение по владельцу: что выяснилось при реализации
|
||||
|
||||
Дописано 2026-08-14 задачей `record-ownership`. Все находки ниже получены одним
|
||||
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
|
||||
против настоящего хранилища на временном каталоге — в ходе ревью того change.
|
||||
|
||||
**Связь с выключенным каскадом не удерживает целостность при удалении.**
|
||||
`core/record_model.go`, `deleteRefRecords`: при `CascadeDelete = false` и
|
||||
необязательном поле хранилище **вынимает** идентификатор из поля связи и
|
||||
сохраняет запись через `SaveNoValidate`. То есть «не уносить записи следом» и
|
||||
«сохранить у них владельца» — разные вещи, и связь даёт только первое.
|
||||
|
||||
**Наружу проходит только ошибка роутера.** `apis/record_crud.go` заворачивает
|
||||
отказ хука в `firstApiError(err, e.BadRequestError("Failed to delete record. Make
|
||||
sure that the record is not part of a required relation reference.", err))`, а
|
||||
`firstApiError` берёт первый аргумент, только если он `*router.ApiError`. Обычная
|
||||
ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную
|
||||
подсказку про обязательную связь — в нашем случае указывающую не на ту связь.
|
||||
|
||||
**`apis/file.go` выдаёт токен файла на предъявителя, а не на файл.** О файле при
|
||||
выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра
|
||||
коллекции проверяет защищённое поле файла по учётной записи **из токена**. Значит
|
||||
чужой токен получить можно всегда, а скачать по нему чужой файл — нет.
|
||||
|
||||
**Проверка сессии с именем коллекции отвечает `403`, а не `401`.**
|
||||
`apis.RequireAuth("users")` пускает только запись названной коллекции; предъявитель
|
||||
из другой — например, владелец панели — узнан, но не годится, и код отказа это
|
||||
различает.
|
||||
|
||||
**Связь в SQLite лежит пустой строкой, а не `NULL`.** `RelationField.ColumnType`
|
||||
даёт `TEXT DEFAULT '' NOT NULL`; сырой запрос и чтение через запись коллекции
|
||||
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
|
||||
не совпадает ни с кем, — но не буквально на уровне схемы.
|
||||
|
||||
+11
-6
@@ -117,12 +117,17 @@
|
||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||
Новой находкой это не считается, пока не измерен рост.
|
||||
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
|
||||
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
|
||||
разграничения по владельцу нет сознательно — [security.md](security.md),
|
||||
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
|
||||
поверхность, выставленная наружу, либо путь к содержимому записи **без**
|
||||
сессии, а не повторение этого факта.
|
||||
- **«Запись без владельца не достаётся никому».** Не дефект: владельца не имеют
|
||||
записи, принятые ботом, — связи чата Telegram с учётной записью приложения
|
||||
сервис не ведёт, её заводит `telegram-account-link`. Ответ такой записи по API
|
||||
совпадает с ответом на несуществующую, и это норма — расшифровку отправитель
|
||||
получает в чат.
|
||||
|
||||
**Прежняя редакция этой строки отменена 2026-08-14.** До задачи
|
||||
`record-ownership` здесь стояло «вошедший видит чужие записи — не дефект и не
|
||||
новость»: разграничения не было сознательно. Теперь оно есть, и такая находка
|
||||
настоящая. Строка оставлена вместо удаления намеренно: прогон, помнящий её
|
||||
прежний вид, выбросил бы регрессию не глядя.
|
||||
|
||||
### Вопросы по темам
|
||||
|
||||
|
||||
+11
-7
@@ -10,9 +10,13 @@
|
||||
|
||||
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||||
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||||
страницу расхода. **Разграничения по владельцу нет:** всякий вошедший видит все
|
||||
записи и все расшифровки, как видел их прежде аноним. Его заводит задача
|
||||
`record-ownership`.
|
||||
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
|
||||
задачей `record-ownership`: и опрос готовности, и файл записи сужены владельцем
|
||||
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
|
||||
доступа — страницы расхода для владельца сервиса.
|
||||
|
||||
Записи, принятые ботом, владельца не имеют и по API не достаются никому: связи
|
||||
чата с учётной записью приложения нет, её заводит `telegram-account-link`.
|
||||
|
||||
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
|
||||
записью приложения он не связан.
|
||||
@@ -173,9 +177,9 @@ Telegram отправителю.
|
||||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||
эти адреса не несут.
|
||||
|
||||
Владения записью в модели данных по-прежнему нет: у задачи нет пользователя.
|
||||
Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не
|
||||
для всякого встречного.
|
||||
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||||
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||||
— читает её тот, кто её принёс.
|
||||
|
||||
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
|
||||
них уже стоит:
|
||||
@@ -183,7 +187,7 @@ Telegram отправителю.
|
||||
| Механизм | Что даёт | Чья задача |
|
||||
| --- | --- | --- |
|
||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
|
||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
|
||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user