Files
transcriber/docs/security.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

430 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Модель угроз
## Периметр
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
`trusted-header-login`, называет она его **заголовком, который ставит обратный
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
**каждый** запрос, и отзыв доступа действует со следующего.
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton`
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
открытость не касается — всякий адрес под корнем приложения узнанного
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
браузеру сервису нечем.
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса.
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
вместе с этим исключением.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели».
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
панели не существует, второго периметра на порту сервиса не осталось, и правилу
прокси нечего закрывать.
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
секрет в настройки коллекции пользователей, и чтение файла базы становилось
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
и аноним войдёт под любым именем. Половину этой беды сервис закрывает сам:
запрос с двумя значениями `Remote-User` не узнаёт никого. Вторую половину
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
репозитория `pet-project-server`, и **требование к нему такое — заголовки
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
человек.
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка:
иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и
бюджет становится общим на весь сервис. Цепочка читается **справа налево**,
доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым
значением распоряжается сам спрашивающий, а правое приписал ближайший к нам
прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит
несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом
покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в
отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто
пришёл, решает адрес самого соединения.
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
именно поэтому.
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
после ухода прежнего владельца, и тогда новый человек при первом же обращении
попадает в **существующую** запись и получает весь её архив — самое
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
может: неизменяемого признака заголовок не приносит. Не допускать
переиспользования — работа провайдера, и это принятая цена, записанная в
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
нечем ни слить, ни убрать.
Отсюда главное следствие, из которого читается всё остальное: **`POST
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
распознавание столько, сколько захочет: ограничитель частоты под корнем
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
по-прежнему нет — её заводит `per-user-size-quota`.
## Недоверенный вход
Что приходит извне и каким каналом.
| Вход | Канал | Кто может слать |
| --- | --- | --- |
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения |
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
Что добавится вместе с целевым периметром — каждый вход появляется своей
задачей, и до неё его нет:
| Вход | Канал | Кто может слать | Чья задача |
| --- | --- | --- | --- |
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
| Хеш-сумма файла | Поле запроса приёма | Отправитель — и она же решает, отдать ли прежнюю запись | `dedup-by-content-hash` |
Ответ языковой модели опаснее прочего в этом списке: он приходит текстом, идёт
в заголовок записи и оттуда на экран — то есть внешний сервис пишет то, что
увидит человек.
## Куда уходит содержимое записи
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
приложения.
Целевой периметр добавляет три пути, каждый — своей задачей:
| Куда | Что уходит | Чья задача |
| --- | --- | --- |
| Языковая модель за шлюзом bifrost | Текст расшифровки целиком | `llm-insights-adapter`, затем `literary-text-level` |
| Канал уведомлений (ntfy через apprise) | Готовый текст либо причина отказа | `ntfy-delivery` |
| Почтовый сервер | Готовый текст либо причина отказа, на адрес из учётной записи | `email-notification` |
Каждая из названных задач обязана оставить строку в этом разделе — там это
записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст
остаётся в приложении.
## Из чего строятся пути и ключи
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался.
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла. Длина
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
роняет заведение временного файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. С
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
распознавания.
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
журнал, ни в метку метрики, ни в ответ.
- **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`.
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
идёт расширение своим полем.
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
единственное, что защищает карточку записи, её текст и её файл сверх владения.
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
- **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска
прежней записи. Ищется она **в пределах одного пользователя**: глобальный
поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
заодно сообщал бы, что запись у кого-то уже есть.
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
плоский каталог — раскладка каталога данных меняется, и это необратимо.
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
идентификатора задачи: имя, данное пользователем, в него не попадает.
## Что разграничивает доступ
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
отзыв доступа у Authelia действует со следующего обращения.
Собственных токенов сервис не принимает вовсе: значения, предъявленного
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
значение било заголовок — им работал владелец панели; панели нет, и правило
приоритета осталось бы правилом без предмета.
**Область узнавания — корень приложения**, и выводится она из объявленного
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
базе, а первый такой запрос с новым именем — записи в неё.
- **Учётная запись** — заводится первым обращением с новым логином и находится
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
библиотечным правилом, не осталось.
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12.
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
больше нечего, и адресов этих не существует.
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
учётной записи на них не заводит. Наружу их закрывает правило обратного
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
эти адреса не несут.
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
частоты на них нет: правило заведено под корень приложения, а раздача стоит
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
кто-то, — узнанному и неузнанному отдаётся одно и то же.
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
есть владелец. Знание идентификатора задачи правом её читать больше не является
— читает её тот, кто её принёс.
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
них уже стоит:
| Механизм | Что даёт | Чья задача |
| --- | --- | --- |
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`).
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
сервиса осталось одно — владение записью.
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
поверхности, открытой в интернет, у неё нет вовсе.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
полный текст речи — лежит файлом в подкаталоге записи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
из базы вместе с собственным входом.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
секрета, которого сегодня в проекте нет вовсе:
1. **Токены пользователей** (`api-tokens`). Токен даёт права своего владельца
целиком. Срока жизни у него нет. В базе лежит только отпечаток, полное
значение показывается один раз при выпуске. Это первый секрет, который
хранится **в базе**, а не в конфигурации.
2. **Ключ языковой модели** и адрес шлюза bifrost (`llm-insights-adapter`).
Утечка оплачивается деньгами.
3. **Пароль почтового сервера** (`email-notification`).
4. **Адрес почты пользователя** — приходит от Authelia и хранится у нас
(`oidc-login`, `email-notification`).
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу.
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
заводить нечего.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
2026-08-11 задачей `no-user-filename-in-log`; запрет проверяют тесты приёма по HTTP на
успешном пути и на пути отказа — они ищут значение, а не имя поля.
**Хвост после последней точки остаётся в журнале, и это объявленное изъятие**
инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток.
Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему
прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация
расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени
файла объявлен необратимым и меняется решением человека.
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
`transcriber_input_file_size_bytes`, `source_format` у
`transcriber_conversion_duration_seconds`) несут расширение, только приведённое к
закрытому перечню известных форматов; всё прочее заменяется значением `other`.
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
по старому значению, покажет пустоту, и это не поломка.
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель.
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
клиента — закрыты задачами `no-user-filename-in-log` и
`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ
пересобирается своими словами: путь, строка, столбец и последний ключ, без текста
библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому
проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же
работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»;
оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек.
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
несёт значения **в нынешней версии** библиотеки.
## Что вне модели
Перечислить явно.
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница.
- **Машина разработчика и то, что он на ней поднимает.** В репозитории лежит
`cmd/devtools` — оснастка разработчика; её подкоманда `proxy` встаёт на место
контура: ставит заголовок `Remote-User` и переправляет запрос сервису, не
проверяя ничего. С 2026-08-15 по 2026-08-22 ту же роль играл `cmd/oidcstub`,
подставной провайдер OIDC. Двух вещей это не отменяет, и обе проверяемы: в
образ оснастка не едет (ступень собирает `./cmd/transcriber` поимённо), а
слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял
её у себя в чужой сети, отвечает за это сам.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
SpeechKit не рассматривается.
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
(паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход
считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда
следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и
защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом
ничем не наблюдается — открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
оплачиваются по факту; потолка на пользователя нет по тому же решению.
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
внешней программе, своей песочницы вокруг неё нет.
- **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
строки потребления текста не содержат.
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
сперва строки приложений — журнал событий, попытка распознавания, структура,
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
кто убрал только файлы, стирает аудио и **оставляет полный текст речи**
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
`delete-record` это единственный способ, и он ручной целиком.