# Модель угроз

## Периметр

**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
без входа только проба здоровья и метрики. Находки строятся против этого —
сегодняшнего — периметра.

Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса.

Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
вместе с этим исключением.

**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели».

**Третий сдвиг — панель администратора.** Решением от 2026-08-11
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
становится PocketBase, и вместе с ним на том же порту появляется панель по
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
панель через Authelia не пускает — у неё свой пароль суперпользователя.
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).

**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.

Отсюда главное следствие, из которого читается всё остальное: **`POST
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
распознавание столько, сколько захочет: ограничитель частоты под корнем
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
по-прежнему нет — её заводит `per-user-size-quota`.

## Недоверенный вход

Что приходит извне и каким каналом.

| Вход | Канал | Кто может слать |
| --- | --- | --- |
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой вошедший; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой вошедший; значение вне закрытого перечня даёт `400` |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |

Что добавится вместе с целевым периметром — каждый вход появляется своей
задачей, и до неё его нет:

| Вход | Канал | Кто может слать | Чья задача |
| --- | --- | --- | --- |
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `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/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** —
  `<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
  отправителя, не применяется: имя отправителя в хранилище не попадает.
  Расширение берётся из имени отправителя через `filepath.Ext` без проверки
  списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
  каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
  расширением. Бакет один на все записи, префикса по пользователю нет. С
  2026-08-14 копия там файлом записи не считается: она существует лишь потому,
  что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
  распознавания.
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
  ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
  задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
  метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
  оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
  ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
  чужим и со своим токеном файла ссылка отвечает «не найдено».
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
  помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
  можно только с коротким токеном файла, который выдаётся по сессии, и запрос
  без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
  токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
  остаётся: **имя файла в хранилище в журнал не пишется**
  — иначе строка журнала вместе с идентификатором записи собирала бы ссылку
  целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
  что защищает карточку записи и её текст.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
  `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
  `/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
  есть доступны они только владельцу панели, — и коллекции, заведённые
  2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
  поверхность хранилища. Коды, снятые прогоном, —
  [database.md](database.md), «Коллекции», норма —
  [storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
  то, что заказано».

Целевой периметр добавляет сюда три вещи, и все три — от новых задач:

- **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска
  прежней записи. Ищется она **в пределах одного пользователя**: глобальный
  поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
  заодно сообщал бы, что запись у кого-то уже есть.
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
  плоский каталог — раскладка каталога данных меняется, и это необратимо.
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
  идентификатора задачи: имя, данное пользователем, в него не попадает.

## Что разграничивает доступ

- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
  кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
  ([database.md](database.md), «Настройки с числовым значением»).
  Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
  бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
  доходит до сервиса, — не значил бы ничего.
  Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
  та же проверка, но она названа здесь отдельно, потому что это второй способ
  предъявить ту же сессию.
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
  хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
  коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
  правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
  здесь «сессия → токен файла → ссылка».
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
  приложение не делает: кого пускать, определяет правило провайдера на этого
  клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
  его не проверить. Клиент, настроенный слишком широко, открывает сервис
  всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
  2026-08-12.
- **Заведение учётной записи** — только входом у провайдера. Собственное
  создание записи, вход по паролю, одноразовый код и восстановление доступа
  выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
  без этого закрытия вход обходился бы двумя запросами.
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
  её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
  прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
  эти адреса не несут.

Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
есть владелец. Знание идентификатора задачи правом её читать больше не является
— читает её тот, кто её принёс.

Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
них уже стоит:

| Механизм | Что даёт | Чья задача |
| --- | --- | --- |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |

Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`).

**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет.

## Что чувствительнее чего

1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
   это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
   колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
   `texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
   `recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
   `record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
   тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
   наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
   Утечка оплачивается деньгами и доступом к бакету.
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
   приложение от чужого имени.

Всё перечисленное лежит в `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`). Текста записей не содержит,
   но говорит, кто и когда пользовался сервисом и сколько; страница расхода
   открыта только владельцу.
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
   пользователей разом, то есть стоит вровень с самым чувствительным из списка
   выше. Второй секрет после токенов пользователей, который лежит **не в
   конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
   по приглашению, которое сервис печатает в журнал при первом запуске. У
   приглашения тридцать минут жизни, и после того как владелец заведён, оно не
   печатается вовсе — иначе строка журнала отдавала бы панель всякому его
   читателю навсегда.

Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
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 — не наша граница.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
  доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
  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 и всеми уровнями текста.
  Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
  строки потребления текста не содержат.

  **Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
  Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
  выше («Что чувствительнее чего»), связи приложений с записью обязательны и
  каскада не имеют, поэтому удаление самой
  строки записи отвергается хранилищем, а удаление её файлов проходит молча.
  Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
  текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
  файлом на диске. Порядок, которым запись убирается на самом деле: сперва
  строки приложений — журнал событий, попытка распознавания вместе с её
  вложением, структура, тексты, — потом сама запись, потом её файлы. До
  `delete-record` это единственный способ, и он ручной целиком.
