- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
65 lines
5.5 KiB
Markdown
65 lines
5.5 KiB
Markdown
# Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале
|
||
|
||
- **Дата:** 2026-08-12
|
||
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
||
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
|
||
- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md)
|
||
|
||
## Решение
|
||
|
||
Поле файла в хранилище **не помечается защищённым**: ссылка
|
||
`/api/files/<коллекция>/<запись>/<имя>` работает без токена, и право пройти по
|
||
ней даёт знание самой ссылки. Взамен имя файла в хранилище **не пишется в журнал
|
||
ни в каком виде** — ни на успешном пути, ни в тексте отказа.
|
||
|
||
## Почему
|
||
|
||
Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для
|
||
этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному
|
||
файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:
|
||
|
||
> Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
|
||
> прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
|
||
> `GET /api/status/:id`, а не ниже.
|
||
|
||
Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это
|
||
условие сломал, и вот чем:
|
||
|
||
> Изъятие выписано под путь на диске: `data/files/<uuid>.ogg` читателю журнала
|
||
> бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки
|
||
> `/api/files/...`, по которой запись скачивает кто угодно; строка журнала стала
|
||
> бы бессрочным ключом к чужому аудио.
|
||
|
||
Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла
|
||
целиком, строка уходила в журнал, а анонимный запрос по собранному адресу
|
||
отвечал `200` с телом записи. Второй путь шёл через отказ выгрузки в Object
|
||
Storage — тот несёт полный URL объекта.
|
||
|
||
Отсюда вторая половина решения, без которой первая недопустима: **отказы
|
||
обрываются**. Наружу идёт свой текст с идентификатором записи, а чужая цепочка
|
||
`%w` — нет. В журнал приёма вместо имени идёт расширение собственным полем;
|
||
прослеживаемость от этого не страдает.
|
||
|
||
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: закрыть
|
||
файлы токеном предложат снова, и без записанной причины предложение выглядит
|
||
бесплатным.
|
||
|
||
## Последствия
|
||
|
||
- `+` ссылка работает без токена, и приёмка проверяется обычным запросом; будущее
|
||
приложение получает файл без отдельного механизма выдачи токенов.
|
||
- `+` изъятие из инварианта приватности не расширилось: в журнале по-прежнему
|
||
только расширение, а не имя.
|
||
- `−` ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не
|
||
удаляются вовсе. Утечка возможна не только журналом — любой будущий экран,
|
||
показывающий ссылку, наследует это свойство.
|
||
- `−` появилась норма, которую держит не построение, а внимание: всякий новый
|
||
отказ хранилища надо обрывать руками. Норму сторожат требование capability
|
||
`storage` и проверка журнала, но компилятор — нет.
|
||
- `−` диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У
|
||
выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK
|
||
(`AccessDenied`, `NoSuchBucket`), в котором адреса не бывает.
|
||
- Решение действует до разграничения доступа: задачи `oidc-login` и
|
||
`record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
|
||
записью.
|