Files
transcriber/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md
T
av c44f0e7582 HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
2026-08-12 17:44:22 +03:00

5.5 KiB
Raw Blame History

Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале

Решение

Поле файла в хранилище не помечается защищённым: ссылка /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 меняют условие, и тогда пометку стоит пересмотреть новой записью.