HTTP API закрыт за вход через OIDC у Authelia

- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
This commit is contained in:
av
2026-08-12 17:44:22 +03:00
parent d676df8a27
commit c44f0e7582
37 changed files with 3997 additions and 65 deletions
+9 -4
View File
@@ -33,10 +33,15 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Что нарушать нельзя. Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object - **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и в колонку Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
всех местах выкладки. **critical** вручную во всех местах выкладки. **critical**
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
пользователей хранилища — туда его кладёт приведение настроек при каждом
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
места запрета это не отменяет.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла - **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
+26
View File
@@ -33,6 +33,32 @@ object_storage_region = "ru-central1"
# Endpoint Object Storage # Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/" object_storage_endpoint = "https://storage.yandexcloud.net/"
# Вход через внешнего провайдера OIDC (Authelia).
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
# API открытым наружу.
[auth]
# Адрес, куда сервис уводит человека на вход
auth_url = "https://auth.example.com/api/oidc/authorization"
# Адрес, где код обменивается на токен
token_url = "https://auth.example.com/api/oidc/token"
# Адрес, откуда берутся сведения о вошедшем
user_info_url = "https://auth.example.com/api/oidc/userinfo"
# Идентификатор клиента, заведённого у провайдера
client_id = "transcriber"
# Секрет клиента; приходит из выкладки, в git не коммитится
client_secret = ""
# Адрес возврата; тот же, что записан клиенту у провайдера
redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true
# Telegram Bot Configuration # Telegram Bot Configuration
[telegram] [telegram]
# Токен Telegram бота (получить у @BotFather в Telegram) # Токен Telegram бота (получить у @BotFather в Telegram)
@@ -0,0 +1,47 @@
# Кого пускать в сервис, решает правило провайдера, а не сервис
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Кого пускать, решает провайдер, а не сервис»
## Решение
Сервис пускает всякого, кого пропустил провайдер, и **своей проверки допуска не
делает**. Кто допущен, определяет правило Authelia на этого клиента — настройка
выкладки, лежащая вне репозитория.
## Почему
Authelia — общий провайдер контура, а не выделенный под этот сервис: учётная
запись в ней есть у всякого, кому её завели ради любого другого сервиса на том же
сервере. Ревью дизайна назвало следствие прямо: механизм, приглашающий «второго
человека», приглашает всех, кто уже есть у провайдера.
Очевидный ответ — проверять принадлежность к названной в конфиге группе своим
кодом. Владелец от него отказался: это завело бы **второе место**, где решается
допуск, и решать его пришлось бы в двух местах согласованно.
Цена отказа названа в источнике и повторена в модели угроз:
> Правило живёт вне репозитория, в настройках выкладки, и сервис на него
> полагается так же, как полагается на обратный прокси в части панели
> администратора. Настроенный слишком широко клиент открывает сервис всем, у кого
> есть учётная запись в общей Authelia, — и проверить это по коду нельзя.
Запись заводится как **намеренный отказ от очевидного подхода**: проверку группы
предложат снова, и без записанной причины она выглядит бесплатной.
## Последствия
- `+` допуск решается в одном месте, а не в двух; изменение круга допущенных не
требует ни правки кода, ни выкладки.
- `+` сервис не читает из ответа провайдера ничего сверх нужного для заведения
записи — ни групп, ни ролей.
- `` защита сервиса стала свойством настройки, лежащей в другом репозитории, и
ревью её проверить не может: ни один проход не увидит, что клиент настроен
слишком широко.
- `` ошибка в настройке клиента не имеет наблюдаемого признака внутри сервиса:
посторонний, которого пропустила Authelia, выглядит как законный пользователь.
- `` разграничения по владельцу нет, поэтому цена ошибки в настройке — все
записи и все расшифровки разом, а не одна учётная запись. Сузит это
`record-ownership`.
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-12 - **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md), - **Источник:** [../../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)
## Решение ## Решение
@@ -0,0 +1,51 @@
# Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
## Решение
Обработчик возврата от провайдера зовёт **собственный адрес хранилища**
`auth-with-oauth2` внутри процесса, через его же роутер, а не по сети и не
разбирая ответ провайдера своими руками.
## Почему
Решение [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
отдало разбор ответа провайдера хранилищу: только тогда учётные записи заводятся
сами и видны в панели. Это решение не пересматривается — пересматривается способ
до него дотянуться.
Проверка исходников библиотеки версии 0.39.10 показала, что обмен наружу не
экспортирован: он живёт неэкспортированной функцией за собственным маршрутом.
Остались три формы, и владелец выбрал первую:
> (а) внутрипроцессный вызов собственного маршрута `auth-with-oauth2`: решение
> 2026-08-11 соблюдено дословно, цена — петля «наш обработчик → наш роутер → наш
> обработчик», разбор JSON-ответа и потеря типизированной ошибки; (б) сборка
> обмена из экспортированных кусков с сохранением записи и связи через `app.Save`:
> прямой код без петли, цена — пересмотр решения 2026-08-11 отдельным ADR; (в)
> отложить вход до появления фронтенда.
Запись заводится как **намеренный отказ от очевидного подхода**: собрать обмен
своими руками выглядит проще и дешевле, и предложение вернётся, если причина не
записана.
## Последствия
- `+` разбор ответа провайдера, заведение учётной записи и связь её с внешним
провайдером остаются за хранилищем — решение 2026-08-11 соблюдено дословно, а
не «по духу».
- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки
под смену формата ответа доезжает само.
- `` петля через собственный роутер: обработчик зовёт сервис, частью которого
сам является. Это новый для проекта вид узла, и его придётся объяснять на
каждом следующем изменении.
- `` ответ разбирается текстом, типизированная ошибка теряется: причина отказа
обмена доступна только кодом состояния.
- `` роутер хранилища пришлось собирать **один раз** и держать полем: его
сборка вешает обработчики на само приложение и без идентификатора, поэтому
повторная не заменяет прежние. Ревью кода нашло это построенным путём —
анонимный запрос копил обработчики без предела, а каждое сохранение задачи
конвейером проходило по всем накопленным.
@@ -0,0 +1,56 @@
# Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 3
## Решение
Поле файла в хранилище **помечается защищённым**, а правило просмотра коллекции
файлов пускает всякого узнанного. Ссылка `/api/files/<коллекция>/<запись>/<имя>`
перестаёт быть правом пройти по ней: нужен короткий токен файла, который берут,
предъявив сессию.
Запись заменяет [ADR-2026-08-12-file-link-open-but-not-logged](ADR-2026-08-12-file-link-open-but-not-logged.md).
## Почему
Прежнее решение было обусловленным и само назвало условие своего пересмотра:
> Решение действует до разграничения доступа: задачи `oidc-login` и
> `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
> записью.
Условие наступило. Прежний довод — «право прочитать задачу даёт знание её
идентификатора, и файл встаёт вровень с `GET /api/status/:id`» — держался на том,
что опрос готовности открыт анонимно. Этот change закрывает опрос за вход, и
файл, оставшийся открытым, стал бы единственным анонимным путём к содержимому
записи — самому чувствительному, что есть у проекта.
Вторая половина прежнего решения остаётся в силе: имя файла в журнал по-прежнему
не пишется. Защищённое поле сужает право пройти, но не отменяет запрета —
строка журнала со ссылкой собирала бы половину ключа.
Пометки самой по себе оказалось мало, и это выяснило ревью кода прогоном:
защищённый файл судится **и** токеном, **и** правилом просмотра коллекции, а
незаданное правило означает «только владелец панели». Файл не получал ни аноним,
ни вошедший — сценарий спеки не исполнялся вовсе. Правило назначено тем же шагом
схемы.
## Последствия
- `+` содержимое записи перестало быть доступным по одному знанию ссылки; после
закрытия API это был последний анонимный путь к нему.
- `+` условие, названное прежней записью, отработало как задумано: решение
пересмотрено записью, а не молча.
- `` ссылка усложнилась для потребителя: браузер с одной кукой файла не
получает, нужен порядок «сессия → токен файла → ссылка». Будущее приложение
обязано этот шаг делать, и задача про прослушивание записи начинается с него.
- `` разграничения по владельцу нет: токен файла берёт всякий вошедший, и по
ссылке он получит **любую** запись, а не только свою. Сужение приносит
`record-ownership`; до неё круг сузился с «кто угодно из интернета» до «кто
угодно из вошедших», и это меньше, чем кажется.
- `` отзыва у выданного токена нет, как не было у ссылки; смягчает только его
короткий срок.
@@ -0,0 +1,55 @@
# Сессия живёт семь суток и не продлевает саму себя
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 6
## Решение
Срок жизни сессии — **семь суток**, назначается при каждом подъёме сервиса.
Продление сессии **выключено**: адрес, которым хранилище меняет предъявленное
значение на новое, закрыт слоем приложения.
## Почему
Умолчание хранилища — пять суток и продлеваемая сессия. Второе делает первое
бессмысленным, и это выяснило ревью кода замером: предъявитель одного живого
значения продлевает себе доступ бессрочно, никуда не входя.
Значение имеет то, на чём держится вся остановка перерасхода. Паспорт опирается
на **отзыв доступа в Authelia** как на способ остановить того, кто тратит слишком
много. Но сервис после входа к провайдеру не обращается: подпись сессии считается
от значений в базе, и отзыв у провайдера до сервиса доходит **только** истечением
срока. При живом продлении не доходит никогда — человек, которому закрыли доступ,
сохраняет его навсегда.
Отвергнуто и названо ценой:
> сверяться с провайдером по расписанию — новая связь с Authelia и обработка её
> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет
> способ остановить того, кто тратит слишком много.
Число семь суток выбрано владельцем как компромисс: реже входить против дольше
ждать, пока отзыв доедет.
Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с
причиной: применённый шаг не переписывается, поэтому число, положенное туда,
разошлось бы со сроком жизни куки при первой же правке — браузер получил бы
новый срок, а хранилище продолжило выдавать прежний.
## Последствия
- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за
семь суток; без этого он не доходил вовсе.
- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном
месте вместе со сроком куки.
- `` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса
нет, так что вход начинается с перехода по адресу входа руками.
- `` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно
закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи;
своего адреса у этого нет.
- `` закрытие продления сделано слоем приложения, а не настройкой коллекции:
библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем.
Слой придётся помнить при всякой правке маршрутов.
+5 -1
View File
@@ -32,9 +32,13 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | | | 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | |
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | | | 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | | | 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | | | 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | | | 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | | | 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
+7 -2
View File
@@ -8,8 +8,8 @@
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из [passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
этого ещё не решено — в разделе «Открытые вопросы». этого ещё не решено — в разделе «Открытые вопросы».
Заведены четыре capability. Три первые нормируют **поведение сервиса** для его Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его
потребителей; четвёртая — исключение из первого абзаца: она нормирует не сервис, а потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает. инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает.
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его - [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
@@ -23,6 +23,11 @@
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12; 2026-08-12;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
видит всё, что видел прежде аноним;
- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой - [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой
его версии собирается сервис: одно число версии Go во всех местах, где она его версии собирается сервис: одно число версии Go во всех местах, где она
названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade` названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade`
+13 -1
View File
@@ -60,6 +60,11 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля *Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем. `users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
говорит, какой формы значение здесь ждут. Пустым оставлен только
`client_secret` — он и есть секрет.
## Поля по дискриминатору `type` ## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
@@ -87,7 +92,7 @@ Ansible из `pet-project-server`). Приложение просто читае
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`, - Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`, `yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`. `yandex.object_storage_secret_access_key`, `auth.client_secret`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, - Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`). владелец — пользователь процесса (`1000:1000`).
- В `config.dist.toml` секретные поля — пустые строки. - В `config.dist.toml` секретные поля — пустые строки.
@@ -115,6 +120,13 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет. места проверки нет.
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
`client_secret` в журнал попасть не должно.
## Структура в коде ## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`. - Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
+14 -3
View File
@@ -96,9 +96,17 @@ capability, и третий смысл развёл бы одно слово п
файлы, ни объекты в Object Storage не удаляются после завершения задачи: файлы, ни объекты в Object Storage не удаляются после завершения задачи:
каталог и бакет растут неограниченно. каталог и бакет растут неограниченно.
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла - **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
не помечено защищённым: право прочитать запись даёт знание её идентификатора, помечено защищённым шагом `202608120001`, а правило просмотра коллекции
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
хранилище **в журнал не пишется** — оно последняя часть ссылки. файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
сужает: создание записи разрешено только контексту обмена OIDC
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
Без этого сужения закрытие API обходится двумя запросами — завести себе
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции. - **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением, `app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
@@ -134,6 +142,9 @@ capability, и третий смысл развёл бы одно слово п
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Срок жизни сессии | 7 суток | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки в 5 суток никем не выбрано |
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у **Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
+71
View File
@@ -99,6 +99,77 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
на `/_/` кодом `200`. на `/_/` кодом `200`.
## Вход через OIDC: что выяснилось при реализации
Дописано 2026-08-12 задачей `oidc-login`. Провенанс общий: чтение исходников
`pocketbase@v0.39.10` из кеша модулей плюс прогоны против настоящего хранилища на
временном каталоге, все — в ходе ревью того change. Живой Authelia в прогонах не
было ни разу: провайдера подменял свой `httptest`-сервер.
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
`CreateRule = ""` (создание доступно анониму) и `PasswordAuth.Enabled = true`
(`migrations/1640988000_init.go`, `core/collection_model_auth_options.go`).
Прогон подтвердил: `POST /api/collections/users/records``200`, следом
`auth-with-password``200` с токеном. То есть закрытие API за вход обходится
двумя запросами, пока эта поверхность не закрыта своим шагом схемы.
**Правило создания нельзя закрывать полностью.** `CreateRule = nil` означает «только
суперпользователь», а запись при первом входе заводит **внутренний** запрос
самого обмена, идущий без таких прав (`apis/record_crud.go`: проверка
`!hasSuperuserAuth && collection.CreateRule == nil`). Прогон: с `nil` вход
кончался `401`, учётных записей `0`. Работает правило
`@request.context = "oauth2"` — контекст ставит сам обмен
(`core.RequestInfoContextOAuth2`), а посторонний запрос приходит с контекстом по
умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен
принимает поля создаваемой записи от вызывающего.
**Обмен кода наружу не экспортирован.** Пакет `apis` отдаёт ошибки, middleware,
`NewRouter`, `Serve` и обёртки; сам обмен — неэкспортированная функция за
маршрутом `POST /api/collections/{c}/auth-with-oauth2`, принимающая `provider`,
`code`, `codeVerifier`, `redirectURL`. Собственный `/api/oauth2-redirect` служит
другому — он ищет клиента realtime-подписки по параметру `state`, то есть
обслуживает всплывающее окно JS-клиента, а не серверный вход.
**`apis.NewRouter` не идемпотентна: собирать её нужно один раз и держать, а не
создавать заново при каждом вызове.**
Она зовёт `bindRealtimeEvents` и `bindUIExtensions`, а те вешают девять
обработчиков **на приложение** и без поля `Id`; `hook.Bind` такому генерирует
новый идентификатор и **добавляет**. Замер: пять вызовов подряд подняли
`OnModelAfterUpdateSuccess` с 4 до 14, а 3000 вызовов — время сотни сохранений
записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только
перезапуск.
**Связывание учётной записи идёт по `sub`, а не найдя — по почте.** Обмен ищет
запись в `_externalAuths` по `providerId`, и лишь затем `FindAuthRecordByEmail`
(`apis/record_auth_with_oauth2.go`). Отсюда цена открытой регистрации: запись,
заведённая посторонним на чужой адрес почты, достаётся первому же настоящему
входу с этим адресом.
**Защищённое поле файла судится двумя вещами сразу** — коротким токеном файла из
строки запроса **и** правилом просмотра коллекции (`apis/file.go`). Незаданное
правило означает «только суперпользователь», поэтому одной пометки `Protected`
мало: прогон показал `404` анониму, вошедшему кукой, вошедшему заголовком и
вошедшему с законно полученным токеном файла — пока правило не назначено.
**Сессия по умолчанию продлеваема бессрочно.** Токен несёт поле
`refreshable=true`, и `POST /api/collections/{c}/auth-refresh` меняет его на
новый с новым сроком. Прогон: три продления подряд, каждое `200`, `exp` растёт.
Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только
слоем приложения поверх маршрута.
**Подпись сессии считается от секрета коллекции и ключа записи**, обе величины в
базе (`core/record_query.go`, `FindAuthRecordByToken`). Отсюда два следствия:
сессия переживает перезапуск сервиса сама, а смена ключа записи
(`Record.RefreshTokenKey()`) обесценивает все её выданные сессии разом.
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
суток; проект умолчание не переопределяет.
## Что отвергнуто и почему ## Что отвергнуто и почему
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто: - **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
+105 -2
View File
@@ -132,6 +132,20 @@
(CLAUDE.md, «Инварианты»). (CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня - `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
тестов два файла, и оба мимо конвейера. тестов два файла, и оба мимо конвейера.
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
состоянию обработчика — класс всплывал трижды, последний раз 2026-08-12 на
уборке носителя состояния входа.
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
собственная регистрация, вход по паролю, одноразовый код, восстановление
доступа, продление сессии. Всё это приходит включённым и закрывается нами
(задача `oidc-login` 2026-08-12).
- `security`: не появился ли второй способ получить сессию к тому же человеку —
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
после входа сервис к провайдеру не обращается, и канал здесь один
(ADR-2026-08-12-session-without-refresh).
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
приложения, — сборка роутера хранилища оказалась именно такой.
### Триггеры метки ### Триггеры метки
@@ -179,7 +193,14 @@ API и имя не откатываются обратной правкой по
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в - `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами; день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата - `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы. отдан внешней программе, и она вне нашей границы;
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
выкладки вне репозитория, и по коду её не проверить
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками — применение `SameSite`, приём
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
этого рода остаются гипотезами.
**Перестали проверять сознательно:** **Перестали проверять сознательно:**
@@ -187,7 +208,13 @@ API и имя не откатываются обратной правкой по
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают 2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
длительность от подставного источника. Своего теста у длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в `adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md). [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер
Telegram: он проверяет токен обращением к Telegram, а боевым токеном
запускаться запрещено. Значит поведенческая верификация живым прогоном
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока
нет.
## Журнал дефектов ## Журнал дефектов
@@ -197,6 +224,82 @@ API и имя не откатываются обратной правкой по
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
выдумывать его задним числом нельзя. выдумывать его задним числом нельзя.
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
- **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи
в коллекции пользователей
- **Симптом:** `users.CreateRule = nil` закрывало создание записи для всех, кроме
владельца панели. Запись при первом входе заводит внутренний запрос самого
обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни
у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы
доступен только через Telegram, и чинилось бы это руками в панели
- **Причина:** закрывали ровно то, ради чего задача затевалась, — самостоятельную
регистрацию, которую хранилище приносит открытой. Глухое `nil` выглядит самым
надёжным её закрытием и отвергает заодно единственный законный путь заведения
записи. Различить их можно: обмен помечает свой запрос контекстом `oauth2`
- **Чем воспроизведён:** тестом против настоящего хранилища с подставным
провайдером: возврат от провайдера отвечал `401`, обращений к токен-эндпоинту
`1`, учётных записей после входа `0`. Причина изолирована тем же прогоном —
с открытым правилом возврат давал `302` и запись появлялась
- **Почему не поймали раньше:** все проверки задачи заводили учётную запись
прямым сохранением, мимо входа, и потому шли по коду, который в бою не
исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по
исходникам библиотеки и враждебный проход падающим тестом
- **Что меняем:** правило сузили до контекста обмена
(`@request.context = "oauth2"`), а в набор проверок добавили вход целиком через
подставного провайдера — от увода до куки сессии. Проверка, заводящая запись
мимо входа, больше не считается покрытием входа
## 2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]
- **Где:** `internal/controller/http/auth_test.go`, проверка уборки носителя
состояния входа; сам дефект — в `auth.go`, уборка стояла в `defer`
- **Симптом:** носитель состояния и проверочного кода не убирался ни на успешном
возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата
держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом
была зелёной и утверждала обратное
- **Причина:** двойная. В коде — `defer` исполняется после того, как ответ уже
начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая
правка их карты до браузера не доезжает. В проверке — `httptest` устроен
зеркально: `Header()` отдаёт живую карту, а снимок лежит отдельно и читается
через `Result()`. Проверка смотрела в живую карту и видела то, чего клиент не
получит
- **Чем воспроизведён:** отдельной программой вне проекта: на настоящем сервере
ответ приходил с пустым `Set-Cookie`, а тот же обработчик под `httptest`
показывал куку в `Header()` и не показывал в `Result()`
- **Почему не поймали раньше:** оракул был ложным по построению, и никакая
регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка
требований и разбор кода, — оба воспроизведением, а не чтением
- **Что меняем:** уборка перенесена до записи ответа; все проверки этого файла
судят по `Result()`. Класс всплывает **третий раз** (2026-08-10 «тесты
http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не
могла упасть»), поэтому он же уходит кандидатом в конвенции: проверка ответа
судит по готовому ответу, а не по изменяемому состоянию обработчика
## 2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]
- **Где:** `internal/controller/http/auth.go`, обмен кода собирал роутер
хранилища на каждый вызов
- **Симптом:** сборка роутера вешает девять обработчиков на само приложение и
без идентификатора, поэтому повторная не заменяет прежние, а добавляет.
Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на
каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно:
атакующий ставит себе куку состояния сам, и сверка сравнивает две его же
величины, а обмен исполняется раньше обращения к провайдеру
- **Причина:** функция сборки выглядит чистой — она возвращает роутер, и по имени
не видно, что она правит приложение. Решение звать собственный адрес хранилища
внутри процесса сделало эту сборку частью горячего пути
- **Чем воспроизведён:** замером на настоящем приложении: пять вызовов подряд
подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов
довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При
недоступном провайдере утечка сохранялась
- **Почему не поймали раньше:** ни один шаг гейта не смотрит на побочные эффекты
вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный
зондом, враждебный падающим тестом, сверка требований чтением
- **Что меняем:** роутер собирается один раз и живёт полем обработчика; в набор
проверок добавлена та, что считает длину очереди обработчиков после двадцати
входов
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил] ## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0` **Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0`
+64 -20
View File
@@ -2,14 +2,20 @@
## Периметр ## Периметр
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и **Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против обратный прокси, а приём записи, опрос готовности и файл записи требуют входа
этого — сегодняшнего — периметра. через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
без входа только проба здоровья и метрики. Находки строятся против этого —
сегодняшнего — периметра.
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
вход для программ по личным токенам, два уровня доступа — пользователь видит и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут; страницу расхода. **Разграничения по владельцу нет:** всякий вошедший видит все
описанное ниже разграничение доступа относится только к Telegram. записи и все расшифровки, как видел их прежде аноним. Его заводит задача
`record-ownership`.
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
записью приложения он не связан.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи **Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
@@ -28,9 +34,18 @@
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур). она вне модели («Что вне модели», строка про контур).
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio` **Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу 2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
запросов и не ограничен по размеру файла. настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
Отсюда главное следствие, из которого читается всё остальное: **`POST
/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не
ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги
на распознавание столько, сколько захочет.
## Недоверенный вход ## Недоверенный вход
@@ -94,9 +109,12 @@ Telegram отправителю.
каталогов, но это единственное, что стоит между входом и именем файла. каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. расширением. Бакет один на все записи, префикса по пользователю нет.
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не - **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется** можно только с коротким токеном файла, который выдаётся по сессии, и запрос
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
остаётся: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку — иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем. целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное, - **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
@@ -124,18 +142,44 @@ Telegram отправителю.
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению. меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу. - **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с кукой `transcriber_session`, живёт семь суток, обесценивается выходом.
остальным. Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
бессрочно, и семисуточный срок — единственное, чем отзыв доступа у провайдера
доходит до сервиса, — не значил бы ничего.
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
та же проверка, но она названа здесь отдельно, потому что это второй способ
предъявить ту же сессию.
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
здесь «сессия → токен файла → ссылка».
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12.
- **Заведение учётной записи** — только входом у провайдера. Собственное
создание записи, вход по паролю, одноразовый код и восстановление доступа
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
без этого закрытия вход обходился бы двумя запросами.
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
эти адреса не несут.
Владения записью в модели данных нет: у задачи нет пользователя. Пока API Владения записью в модели данных по-прежнему нет: у задачи нет пользователя.
анонимен, знание UUID задачи и есть право её читать. Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не
для всякого встречного.
Целевой периметр заводит четыре механизма вместо одного белого списка: Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
них уже стоит:
| Механизм | Что даёт | Чья задача | | Механизм | Что даёт | Чья задача |
| --- | --- | --- | | --- | --- | --- |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` | | Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты**сделано 2026-08-12** | `oidc-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` | | Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
@@ -1,6 +1,9 @@
package pocketbase package pocketbase
import ( import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations" "github.com/pocketbase/pocketbase/migrations"
@@ -15,6 +18,7 @@ import (
// `apis.Serve` прежде, чем поднять сервер. // `apis.Serve` прежде, чем поднять сервер.
func init() { func init() {
migrations.Register(up202608110001, down202608110001, "202608110001_init.go") migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
migrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
} }
func up202608110001(app core.App) error { func up202608110001(app core.App) error {
@@ -119,4 +123,130 @@ func down202608110001(app core.App) error {
return nil return nil
} }
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
// применяется, потому что оно никем не выбрано.
//
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
// провайдера. Причина та же: применённый шаг не переписывается, и число,
// положенное туда, разошлось бы со сроком жизни куки при первой же правке —
// браузер получил бы новый срок, а хранилище продолжило выдавать прежний.
const SessionDuration = 7 * 24 * 60 * 60
// defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат.
const defaultAuthTokenDuration = 1209600
// up202608120001 закрывает поверхность, которую хранилище приносит своим
// системным шагом, и защищает файл записи.
//
// Коллекция пользователей заводится библиотекой с открытым созданием записи и
// включённым входом по паролю. Без этого шага закрытие API обходится двумя
// запросами: завести себе учётную запись, войти паролем, предъявить полученное
// заголовком. Отдельная цена открытого создания — захват учётной записи: обмен
// кода ищет запись сперва по неизменяемому признаку провайдера, а не найдя —
// по адресу почты, и запись, заведённая посторонним на чужой адрес, достаётся
// первому же настоящему входу с этим адресом.
func up202608120001(app core.App) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
// Завести учётную запись можно только входом у провайдера.
//
// Правило именно такое, а не `nil`: запись при первом входе заводит
// внутренний запрос самого обмена, и он идёт без прав суперпользователя —
// глухое `nil` отвергло бы его наравне с посторонним, и войти не смог бы
// никто. Контекст `oauth2` ставит обмен (`core.RequestInfoContextOAuth2`),
// а посторонний запрос приходит с контекстом по умолчанию.
//
// Открывать правило пустой строкой нельзя: публичный обмен принимает поля
// создаваемой записи от вызывающего, и всякий владелец учётной записи у
// провайдера задал бы их сам.
users.CreateRule = ptr(`@request.context = "oauth2"`)
users.PasswordAuth.Enabled = false
users.OTP.Enabled = false
// Провайдер включается здесь с пустыми значениями: адреса, идентификатор
// клиента и секрет приходят из конфига при каждом подъёме. Положенный сюда
// секрет не пережил бы ротации — применённый шаг не переписывается.
users.OAuth2.Enabled = true
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to close users collection surface: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
// Ссылка на файл перестаёт быть правом пройти по ней: до этого шага знание
// ссылки и было доступом, а отзыва у неё нет. Конвейер этим не затронут —
// он читает файл из файловой системы хранилища, а не по ссылке.
//
// Комментарий прежнего шага утверждает обратное — «защищённым поле не
// помечено намеренно». Прежний шаг не переписывается, поэтому решение
// отменяется здесь: право прочитать запись больше не даёт знание её
// идентификатора.
field, ok := files.Fields.GetByName("file").(*core.FileField)
if !ok {
return errors.New("files collection has no file field")
}
field.Protected = true
// Одной пометки мало: защищённый файл судится ещё и правилом просмотра
// коллекции, а незаданное правило означает «только владелец панели» — файл
// не получил бы и вошедший. Правило пускает всякого узнанного: владельца у
// записи ещё нет, и сужать выборку эта задача не должна.
files.ViewRule = ptr(`@request.auth.id != ""`)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to protect record file: %w", err)
}
return nil
}
// down202608120001 возвращает умолчания библиотеки — те, что стояли до шага.
//
// Открытое создание записи сюда не возвращается намеренно: это ровно то, что
// шаг и закрывал, и откат, восстанавливающий анонимную регистрацию, оставил бы
// сервис хуже, чем он был до задачи. Срок жизни сессии возвращается
// умолчанием, а не нулём: нулевую длительность валидация коллекции отвергает, и
// прежний откат падал на ней, не дойдя до снятия защиты с файла.
func down202608120001(app core.App) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.CreateRule = nil
users.PasswordAuth.Enabled = true
users.OTP.Enabled = true
users.OAuth2.Enabled = false
users.OAuth2.Providers = nil
users.AuthToken.Duration = defaultAuthTokenDuration
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to restore users collection: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
if field, ok := files.Fields.GetByName("file").(*core.FileField); ok {
field.Protected = false
}
files.ViewRule = nil
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to unprotect record file: %w", err)
}
return nil
}
func ptr[T any](v T) *T { return &v } func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,61 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
const ProviderName = "oidc"
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
// коллекции.
type ProviderSettings struct {
AuthURL string
TokenURL string
UserInfoURL string
ClientID string
ClientSecret string
}
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
// к значениям конфига.
//
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
// только руками в панели.
//
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
// имя коллекции, а не значения.
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
// сроком жизни куки.
users.AuthToken.Duration = SessionDuration
users.OAuth2.Enabled = true
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: ProviderName,
ClientId: settings.ClientID,
ClientSecret: settings.ClientSecret,
AuthURL: settings.AuthURL,
TokenURL: settings.TokenURL,
UserInfoURL: settings.UserInfoURL,
DisplayName: "Authelia",
}}
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
}
return nil
}
+70
View File
@@ -2,7 +2,10 @@ package config
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"sort"
"strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
@@ -12,6 +15,7 @@ type Config struct {
Storage StorageConfig `toml:"storage"` Storage StorageConfig `toml:"storage"`
Yandex YandexConfig `toml:"yandex"` Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"` Telegram TelegramConfig `toml:"telegram"`
Auth AuthConfig `toml:"auth"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -42,6 +46,69 @@ type TelegramConfig struct {
UpdateTimeout int `toml:"update_timeout"` UpdateTimeout int `toml:"update_timeout"`
} }
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
// секрет, положенный однажды шагом, не пережил бы ротации.
type AuthConfig struct {
AuthURL string `toml:"auth_url"`
TokenURL string `toml:"token_url"`
UserInfoURL string `toml:"user_info_url"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
RedirectURL string `toml:"redirect_url"`
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
// выключается только для локального запуска по `http://localhost`, где
// браузер такую куку не сохранит.
SecureCookie bool `toml:"secure_cookie"`
}
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
// открытым наружу, а узнать об этом было бы неоткуда.
//
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
// молча падает целиком, вместе с ботом и воркерами.
func (c AuthConfig) Validate() error {
values := map[string]string{
"auth_url": c.AuthURL,
"token_url": c.TokenURL,
"user_info_url": c.UserInfoURL,
"client_id": c.ClientID,
"client_secret": c.ClientSecret,
"redirect_url": c.RedirectURL,
}
missing := make([]string, 0, len(values))
for name, value := range values {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
// Названы имена ключей, а не значения: значение `client_secret` в
// сообщение об ошибке попасть не должно, оно уедет в журнал.
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
}
malformed := make([]string, 0, 4)
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
parsed, err := url.Parse(values[name])
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
malformed = append(malformed, name)
}
}
if len(malformed) > 0 {
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
}
return nil
}
// DefaultConfig returns a Config with default values // DefaultConfig returns a Config with default values
func defaultConfig() *Config { func defaultConfig() *Config {
return &Config{ return &Config{
@@ -66,6 +133,9 @@ func defaultConfig() *Config {
BotToken: "", BotToken: "",
UpdateTimeout: 10, UpdateTimeout: 10,
}, },
Auth: AuthConfig{
SecureCookie: true,
},
} }
} }
+97
View File
@@ -0,0 +1,97 @@
package config
import (
"strings"
"testing"
)
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
// исполнялась ни разу.
func validAuthConfig() AuthConfig {
return AuthConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "secret-value",
RedirectURL: "https://transcriber.example.com/auth/callback",
}
}
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
if err := validAuthConfig().Validate(); err != nil {
t.Fatalf("заполненный конфиг отвергнут: %v", err)
}
}
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
cases := map[string]func(*AuthConfig){
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
"client_id": func(c *AuthConfig) { c.ClientID = "" },
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
}
for key, clear := range cases {
t.Run(key, func(t *testing.T) {
cfg := validAuthConfig()
clear(&cfg)
err := cfg.Validate()
if err == nil {
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
}
if !strings.Contains(err.Error(), key) {
t.Fatalf("имя ключа %s не названо: %v", key, err)
}
})
}
}
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
// и значения секрета в нём быть не должно — только имя ключа.
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
cfg := validAuthConfig()
cfg.ClientSecret = "super-secret-value"
cfg.AuthURL = ""
err := cfg.Validate()
if err == nil {
t.Fatal("отказа нет")
}
if strings.Contains(err.Error(), "super-secret-value") {
t.Fatalf("значение секрета попало в текст отказа: %v", err)
}
}
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
cases := map[string]string{
"без схемы": "auth.example.com/api/oidc/authorization",
"пробел спереди": " https://auth.example.com/authorize",
"чужая схема": "ftp://auth.example.com/authorize",
"пустой хост": "https:///authorize",
"не адрес вовсе": "todo: заполнить",
}
for name, value := range cases {
t.Run(name, func(t *testing.T) {
cfg := validAuthConfig()
cfg.AuthURL = value
err := cfg.Validate()
if err == nil {
t.Fatalf("негодный адрес %q пропущен", value)
}
if !strings.Contains(err.Error(), "auth_url") {
t.Fatalf("имя ключа не названо: %v", err)
}
})
}
}
+364
View File
@@ -0,0 +1,364 @@
package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/security"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
)
const (
// SessionCookieName — имя куки сессии. Имя нормативно: его смена молча
// выкидывает всех вошедших.
SessionCookieName = "transcriber_session"
// stateCookieName — носитель состояния и проверочного кода PKCE. Живёт
// один вход и убирается на возврате, каким бы тот ни был.
stateCookieName = "transcriber_login"
// stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель
// не нужен, а вечный носитель надолго фиксирует состояние.
stateCookieMaxAge = 10 * 60
// exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий
// провайдер держит обработчик возврата открытым неограниченно долго, и «медленный»
// становится неотличим от «отказал».
exchangeTimeout = 15 * time.Second
)
// AuthHandler ведёт вход, возврат от провайдера и выход.
//
// Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11.
// Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом,
// поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же
// роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные
// записи заводит хранилище и они видны в панели.
type AuthHandler struct {
app core.App
logger *slog.Logger
authURL string
redirectURL string
clientID string
secureCookie bool
// storageMux — роутер хранилища, через который идёт обмен кода. Собирается
// один раз: сборка вешает обработчики на само приложение и без
// идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним.
// Собранный на каждый вход, он копил бы их без предела — и копил бы по
// запросу анонима, потому что обмен исполняется раньше обращения к
// провайдеру.
storageMux http.Handler
storageMuxOnce sync.Once
storageMuxErr error
}
type AuthHandlerConfig struct {
AuthURL string
RedirectURL string
ClientID string
SecureCookie bool
}
func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler {
if logger == nil {
logger = slog.Default()
}
return &AuthHandler{
app: app,
logger: logger,
authURL: cfg.AuthURL,
redirectURL: cfg.RedirectURL,
clientID: cfg.ClientID,
secureCookie: cfg.SecureCookie,
}
}
// Register вешает адреса входа вне пространства `/api`: оно поделено с
// собственными адресами хранилища.
func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
// Продление сессии закрывается на всём роутере: адрес приносит хранилище
// своим, и перехватить его можно только слоем.
r.Bind(BlockSessionRefresh())
r.GET("/auth/login", h.Login)
r.GET("/auth/callback", h.Callback)
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
// чужой ссылке.
//
// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
// значение годным. Требования сессии при этом нет: выход без неё убирает
// куку и молчит.
r.POST("/auth/logout", h.Logout).Bind(SessionFromCookie())
}
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
// PKCE у браузера.
func (h *AuthHandler) Login(e *core.RequestEvent) error {
state := security.RandomString(32)
verifier := security.RandomString(43)
e.SetCookie(&http.Cookie{
Name: stateCookieName,
Value: state + ":" + verifier,
Path: "/",
MaxAge: stateCookieMaxAge,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
query := url.Values{}
query.Set("response_type", "code")
query.Set("client_id", h.clientID)
query.Set("redirect_uri", h.redirectURL)
query.Set("scope", "openid profile email")
query.Set("state", state)
query.Set("code_challenge", security.S256Challenge(verifier))
query.Set("code_challenge_method", "S256")
separator := "?"
if strings.Contains(h.authURL, "?") {
separator = "&"
}
return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode())
}
// Callback принимает возврат от провайдера, сверяет состояние и меняет код на
// сессию средствами хранилища.
func (h *AuthHandler) Callback(e *core.RequestEvent) error {
// Носитель убирается всегда — и на успехе, и на отказе, — и убирается
// **до** записи ответа. Отложенная уборка не работает вовсе: заголовки
// фиксируются в момент, когда ответ начинают писать, и позднейшая правка их
// карты до браузера не доезжает. Состояние одноразовое ровно этим: пока
// носитель жив, переигранный возврат проходит сверку.
h.clearStateCookie(e)
query := e.Request.URL.Query()
// Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка
// сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе
// обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит».
if providerError := query.Get("error"); providerError != "" {
h.logger.Debug("Login rejected by provider",
"reason", knownProviderError(providerError), "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
state, verifier, err := h.readStateCookie(e)
if err != nil {
h.logger.Debug("Login state is missing or malformed",
"error", err, "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
if query.Get("state") != state {
h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
code := query.Get("code")
if code == "" {
h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
token, err := h.exchange(e.Request.Context(), code, verifier)
if err != nil {
// Отказ обмена — уже про сервис и его связь с провайдером, поэтому
// уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель
// входа.
h.logger.Error("Failed to exchange provider code",
"error", err, "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
h.setSessionCookie(e, token)
return e.Redirect(http.StatusFound, "/")
}
// Logout обесценивает выданные учётной записи сессии и убирает куку.
//
// Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке
// выход, разошедшийся с одновременным входом, оставил бы годную сессию, а
// человек был бы уверен, что вышел.
func (h *AuthHandler) Logout(e *core.RequestEvent) error {
if e.Auth != nil {
// Ключ токенов обновляется у свежей записи: между чтением и записью
// могла пройти чужая правка, и полное сохранение устаревшей записи
// затёрло бы её.
record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id)
if err != nil {
h.logger.Error("Failed to load account for logout", "error", err, "transport", "http")
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
}
record.RefreshTokenKey()
if err := h.app.Save(record); err != nil {
h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http")
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
}
}
h.clearSessionCookie(e)
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
// exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не
// идёт: роутер поднимается тот же, что обслуживает внешние запросы.
func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
defer cancel()
body, err := json.Marshal(map[string]string{
"provider": pbrepo.ProviderName,
"code": code,
"codeVerifier": verifier,
"redirectURL": h.redirectURL,
})
if err != nil {
return "", fmt.Errorf("failed to build exchange request: %w", err)
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
"/api/collections/users/auth-with-oauth2",
strings.NewReader(string(body)),
)
if err != nil {
return "", fmt.Errorf("failed to build exchange request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
handler, err := h.storageHandler()
if err != nil {
return "", err
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
// Тело ответа наружу не выносится: в нём приезжает описание отказа
// провайдера, а оно принадлежит журналу, а не человеку.
return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code)
}
var response struct {
Token string `json:"token"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
return "", fmt.Errorf("failed to read exchange response: %w", err)
}
if response.Token == "" {
return "", errors.New("exchange response carries no session")
}
return response.Token, nil
}
// knownProviderError приводит причину отказа к перечню известных.
//
// Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без
// приведения аноним пишет в журнал что угодно и сколько угодно — предел один,
// размер заголовков. Журнал же единственное место, где наблюдаются инварианты о
// молчаливой потере задачи, и вытеснять его чужим текстом нельзя.
//
// Приём тот же, каким расширение записи приводится к перечню форматов.
func knownProviderError(value string) string {
switch value {
case "access_denied", "invalid_request", "invalid_scope", "server_error",
"temporarily_unavailable", "unauthorized_client", "unsupported_response_type",
"interaction_required", "login_required", "consent_required":
return value
default:
return "other"
}
}
// storageHandler собирает роутер хранилища один раз и отдаёт его всем
// последующим обменам.
func (h *AuthHandler) storageHandler() (http.Handler, error) {
h.storageMuxOnce.Do(func() {
router, err := apis.NewRouter(h.app)
if err != nil {
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
return
}
mux, err := router.BuildMux()
if err != nil {
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
return
}
h.storageMux = mux
})
return h.storageMux, h.storageMuxErr
}
func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) {
cookie, err := e.Request.Cookie(stateCookieName)
if err != nil {
return "", "", fmt.Errorf("login state cookie is missing: %w", err)
}
state, verifier, found := strings.Cut(cookie.Value, ":")
if !found || state == "" || verifier == "" {
return "", "", errors.New("login state cookie is malformed")
}
return state, verifier, nil
}
func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) {
e.SetCookie(&http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
MaxAge: pbrepo.SessionDuration,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) {
e.SetCookie(&http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) {
e.SetCookie(&http.Cookie{
Name: stateCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
+538
View File
@@ -0,0 +1,538 @@
package http
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
)
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
func TestApiRequiresSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
t.Run("приём записи без сессии", func(t *testing.T) {
req := createMultipartRequest(t, "test.mp3", []byte("audio"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.NotContains(t, w.Body.String(), "job_id")
// Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в
// хранилище.
files, err := env.app.FindAllRecords(pbrepo.FilesCollection)
require.NoError(t, err)
assert.Empty(t, files)
jobs, err := env.app.FindAllRecords(pbrepo.JobsCollection)
require.NoError(t, err)
assert.Empty(t, jobs)
})
t.Run("опрос готовности без сессии", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/status/anything", nil)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.NotContains(t, w.Body.String(), "transcription_text")
assert.NotContains(t, w.Body.String(), "created_at")
})
}
// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
// перебирается список заведённых задач.
func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, created.Code)
jobs, err := env.app.FindAllRecords(pbrepo.JobsCollection)
require.NoError(t, err)
require.Len(t, jobs, 1)
existing := httptest.NewRecorder()
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/api/status/"+jobs[0].Id, nil))
missing := httptest.NewRecorder()
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil))
assert.Equal(t, http.StatusUnauthorized, existing.Code)
assert.Equal(t, missing.Code, existing.Code)
}
// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
// роутер с теми же двумя адресами.
func TestOpenEndpointsStayOpen(t *testing.T) {
app := newTestStorage(t)
r, err := apis.NewRouter(app)
require.NoError(t, err)
r.GET("/health", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
r.GET("/metrics", func(e *core.RequestEvent) error {
return e.String(http.StatusOK, "# metrics")
})
mux, err := r.BuildMux()
require.NoError(t, err)
for _, path := range []string{"/health", "/metrics"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
}
}
// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
// вошедших не выкидывает.
func TestSessionSurvivesRestart(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
before := httptest.NewRecorder()
env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, before.Code)
// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
// поверх прежнего каталога данных.
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
env.handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
// ненайденную задачу, а не про отсутствующую сессию.
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
// сессии, а не только убирает куку.
func TestLogoutClosesAccess(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
env.handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
logoutResponse := httptest.NewRecorder()
mux.ServeHTTP(logoutResponse, logout)
require.Equal(t, http.StatusOK, logoutResponse.Code)
// Куку выход убирает.
assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
// себе не даёт: унесённое значение работало бы до истечения срока.
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
mux.ServeHTTP(afterResponse, after)
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}
// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
func TestLogoutWhenAccountIsGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
// учётной записи в панели.
require.NoError(t, env.app.Delete(env.account))
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, logout)
// Записи нет — проверка сессии её не находит, и до обесценивания дело не
// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
// не осталось, потому что не осталось учётной записи.
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
env.mux.ServeHTTP(afterResponse, after)
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}
// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
func TestLogoutWithoutSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
}
// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
// у браузера.
func TestLoginRedirectsToProvider(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
require.Equal(t, http.StatusFound, w.Code)
location := w.Result().Header.Get("Location")
assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
assert.Contains(t, location, "code_challenge_method=S256")
assert.Contains(t, location, "client_id=transcriber")
// Носитель состояния несёт те же признаки защиты, что и кука сессии.
stateCookie := w.Result().Header.Get("Set-Cookie")
assert.Contains(t, stateCookie, stateCookieName)
assert.Contains(t, stateCookie, "HttpOnly")
assert.Contains(t, stateCookie, "Secure")
assert.Contains(t, stateCookie, "SameSite=Lax")
}
// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
// открывает и учётной записи не заводит.
func TestCallbackRejectsForeignState(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
accountsBefore, err := env.app.FindAllRecords("users")
require.NoError(t, err)
cases := []struct {
name string
cookie *http.Cookie
query string
}{
{
name: "состояния не выдавали вовсе",
cookie: nil,
query: "?code=whatever&state=foreign",
},
{
name: "состояние не совпало с выданным",
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
query: "?code=whatever&state=foreign",
},
{
name: "провайдер вернул отказ",
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
query: "?error=access_denied&state=issued",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
accountsAfter, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Len(t, accountsAfter, len(accountsBefore))
// Носитель убирается и на отказном возврате: иначе состояние
// осталось бы годным для новой попытки.
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
})
}
}
// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
func TestHeaderBeatsCookie(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
req.Header.Set("Authorization", env.session)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
// Прошёл заголовок: иначе негодная кука дала бы отказ.
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
// двумя запросами.
func TestSelfServiceAccountsAreClosed(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusOK, w.Code)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
})
t.Run("вход паролем недоступен", func(t *testing.T) {
body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
})
}
// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
// ревью дизайна: не печатается ничто, что даёт доступ.
//
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
// другим ключом, поиск по ключу не разбудил бы.
func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, created.Code)
journal := env.journal.String()
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
assert.NotContains(t, journal, env.session,
"значение сессии в журнале: строка стала бы ключом к чужому доступу")
assert.NotContains(t, journal, env.account.Email(),
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
}
// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
// приведении настроек провайдера к конфигу.
func TestProviderSecretIsNotLogged(t *testing.T) {
app := newTestStorage(t)
const secret = "super-secret-client-value"
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: secret,
}))
// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
// журнале прошла бы на невыполненной работе.
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
require.True(t, found)
assert.Equal(t, secret, provider.ClientSecret)
}
// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
// не переписывается.
func TestProviderSecretRotationReachesStorage(t *testing.T) {
app := newTestStorage(t)
settings := pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "first-secret",
}
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
settings.ClientSecret = "rotated-secret"
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
require.True(t, found)
assert.Equal(t, "rotated-secret", provider.ClientSecret)
}
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
func TestRecordFileIsProtected(t *testing.T) {
app := newTestStorage(t)
files, err := app.FindCollectionByNameOrId(pbrepo.FilesCollection)
require.NoError(t, err)
field, ok := files.Fields.GetByName("file").(*core.FileField)
require.True(t, ok)
assert.True(t, field.Protected,
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
}
// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
// ссылке.
func TestRecordFileNeedsSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
require.Equal(t, http.StatusCreated, created.Code)
files, err := env.app.FindAllRecords(pbrepo.FilesCollection)
require.NoError(t, err)
require.Len(t, files, 1)
names := files[0].GetStringSlice("file")
require.Len(t, names, 1)
link := "/api/files/" + pbrepo.FilesCollection + "/" + files[0].Id + "/" + names[0]
anonymous := httptest.NewRecorder()
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
// Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже
// своего существования. До пометки поля защищённым эта же ссылка отдавала
// содержимое кому угодно — знание ссылки и было доступом.
assert.Equal(t, http.StatusNotFound, anonymous.Code,
"ссылка отдала файл без сессии: знание ссылки снова стало доступом")
assert.NotContains(t, anonymous.Body.String(), "audio content")
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
fileRepo := pbrepo.NewFileRepository(env.app)
reader, err := fileRepo.Open(files[0].Id)
require.NoError(t, err)
defer func() {
assert.NoError(t, reader.Close())
}()
content := make([]byte, len("audio content"))
_, err = reader.Read(content)
require.NoError(t, err)
assert.Equal(t, "audio content", string(content))
}
// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
// умолчанием библиотеки в пять суток.
//
// Назначается он приведением настроек при подъёме, а не шагом схемы: применённый
// шаг не переписывается, и число, положенное туда, разошлось бы со сроком жизни
// куки при первой же правке.
func TestSessionLifetimeIsAssigned(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "local-test-secret",
}))
users, err = app.FindCollectionByNameOrId("users")
require.NoError(t, err)
assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
}
+352
View File
@@ -0,0 +1,352 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
)
// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.
// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
// считая обращения: по счётчику видно, дошло ли до сети вообще.
type fakeProvider struct {
server *httptest.Server
tokenHits int
failToken bool
subject string
emailValue string
}
func newFakeProvider(t *testing.T) *fakeProvider {
t.Helper()
provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}
write := func(w http.ResponseWriter, body string) {
if _, err := w.Write([]byte(body)); err != nil {
t.Errorf("подставной провайдер не ответил: %v", err)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
provider.tokenHits++
if provider.failToken {
w.WriteHeader(http.StatusBadRequest)
write(w, `{"error":"invalid_grant"}`)
return
}
w.Header().Set("Content-Type", "application/json")
write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
})
provider.server = httptest.NewServer(mux)
t.Cleanup(provider.server.Close)
return provider
}
// loginEnv — окружение проверки входа: хранилище с настроенным подставным
// провайдером и собранный роутер со всеми слоями.
type loginEnv struct {
app core.App
mux http.Handler
handler *AuthHandler
provider *fakeProvider
}
func setupLoginEnv(t *testing.T) *loginEnv {
t.Helper()
app := newTestStorage(t)
provider := newFakeProvider(t)
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: provider.server.URL + "/authorize",
TokenURL: provider.server.URL + "/token",
UserInfoURL: provider.server.URL + "/userinfo",
ClientID: "transcriber",
ClientSecret: "local-test-secret",
}))
handler := NewAuthHandler(app, AuthHandlerConfig{
AuthURL: provider.server.URL + "/authorize",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(app)
require.NoError(t, err)
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
}
// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
// выданным состоянием — тем, что сервис ждёт обратно.
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
t.Helper()
w := httptest.NewRecorder()
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
require.Equal(t, http.StatusFound, w.Code)
for _, c := range w.Result().Cookies() {
if c.Name == stateCookieName {
cookie = c
}
}
require.NotNil(t, cookie, "носитель состояния не поставлен")
location, err := url.Parse(w.Result().Header.Get("Location"))
require.NoError(t, err)
state = location.Query().Get("state")
require.NotEmpty(t, state)
return cookie, state
}
// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
// провайдера, и он получает сессию.
//
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
// работающим: прочие проверки заводят запись мимо входа.
func TestLoginCreatesAccountAndSession(t *testing.T) {
env := setupLoginEnv(t)
before, err := env.app.FindAllRecords("users")
require.NoError(t, err)
require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")
after, err := env.app.FindAllRecords("users")
require.NoError(t, err)
require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")
var session *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == SessionCookieName {
session = c
}
}
require.NotNil(t, session, "кука сессии не поставлена")
// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
// уносит её по незашифрованному соединению.
assert.True(t, session.HttpOnly)
assert.True(t, session.Secure)
assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
assert.NotEmpty(t, session.Value)
// Носитель состояния убран — и убран так, что это видно готовому ответу, а
// не только живой карте заголовков.
var cleared bool
for _, c := range w.Result().Cookies() {
if c.Name == stateCookieName && c.MaxAge < 0 {
cleared = true
}
}
assert.True(t, cleared, "носитель состояния пережил возврат")
// Выданная сессия открывает доступ к закрытым адресам.
check := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
check.AddCookie(session)
checkResponse := httptest.NewRecorder()
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
NewTranscribeHandler(pbrepo.NewTranscriptJobRepository(env.app), nil, nil).Register(r)
checkMux, err := r.BuildMux()
require.NoError(t, err)
checkMux.ServeHTTP(checkResponse, check)
assert.Equal(t, http.StatusNotFound, checkResponse.Code,
"сессия не открыла доступ: получен %d", checkResponse.Code)
}
// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
// пускает постороннего.
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
env := setupLoginEnv(t)
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
"посторонний завёл себе учётную запись: %s", w.Body.String())
accounts, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Empty(t, accounts)
}
// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
//
// Сборка роутера хранилища вешает обработчики на само приложение и без
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
// что обмен исполняется раньше обращения к провайдеру.
func TestCallbackDoesNotLeakHooks(t *testing.T) {
env := setupLoginEnv(t)
count := func() int {
hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
return hook.Len()
}
// Первый вход собирает роутер — с него и считаем.
cookie, state := env.startLogin(t)
first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
first.AddCookie(cookie)
env.mux.ServeHTTP(httptest.NewRecorder(), first)
before := count()
for range 20 {
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
req.AddCookie(cookie)
env.mux.ServeHTTP(httptest.NewRecorder(), req)
}
assert.Equal(t, before, count(),
"обработчики копятся: 20 входов добавили %d", count()-before)
}
// TestSessionRefreshIsClosed: сессия не продлевает саму себя.
//
// При живом продлении срок её жизни ничего не значит, а вместе с ним перестаёт
// работать единственный канал, которым отзыв доступа у провайдера доходит до
// сервиса.
func TestSessionRefreshIsClosed(t *testing.T) {
env := setupLoginEnv(t)
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code)
var session *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == SessionCookieName {
session = c
}
}
require.NotNil(t, session)
refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
refresh.Header.Set("Authorization", session.Value)
refreshResponse := httptest.NewRecorder()
env.mux.ServeHTTP(refreshResponse, refresh)
assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
"сессия продлилась: %s", refreshResponse.Body.String())
// И нового значения в ответе нет — продлевать нечем.
assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
}
// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
func TestCallbackRejectsProviderFailure(t *testing.T) {
env := setupLoginEnv(t)
env.provider.failToken = true
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
for _, c := range w.Result().Cookies() {
assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
}
accounts, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Empty(t, accounts)
}
// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
// отдаётся анониму.
func TestRecordFileNeedsSessionAndToken(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
require.Equal(t, http.StatusCreated, created.Code)
files, err := env.app.FindAllRecords(pbrepo.FilesCollection)
require.NoError(t, err)
require.Len(t, files, 1)
names := files[0].GetStringSlice("file")
require.Len(t, names, 1)
link := "/api/files/" + pbrepo.FilesCollection + "/" + files[0].Id + "/" + names[0]
// Аноним не проходит.
anonymous := httptest.NewRecorder()
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
assert.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
assert.NotContains(t, anonymous.Body.String(), "audio content")
// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
// защищённый файл судится этим токеном, а не сессионной кукой.
tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
tokenRequest.Header.Set("Authorization", env.session)
tokenResponse := httptest.NewRecorder()
env.mux.ServeHTTP(tokenResponse, tokenRequest)
require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())
var payload struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
require.NotEmpty(t, payload.Token)
withToken := httptest.NewRecorder()
env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))
assert.Equal(t, http.StatusOK, withToken.Code,
"вошедший не получил файл: %d", withToken.Code)
assert.Contains(t, withToken.Body.String(), "audio content")
}
+72
View File
@@ -0,0 +1,72 @@
package http
import (
"net/http"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
)
// RefreshPath — адрес хранилища, которым сессия продлевает саму себя.
const RefreshPath = "/api/collections/users/auth-refresh"
// BlockSessionRefresh закрывает продление сессии.
//
// Хранилище выдаёт сессию продлеваемой: предъявитель значения меняет его на
// новое, с новым сроком, и делает это сколько угодно раз, никуда не входя. При
// живом продлении срок жизни сессии перестаёт что-либо значить, а вместе с ним
// перестаёт работать единственный канал, которым отзыв доступа у провайдера
// доходит до сервиса, — сервис после входа к провайдеру не обращается.
//
// Решение владельца от 2026-08-12: продление выключено, цена — вход раз в
// семь суток.
func BlockSessionRefresh() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberBlockSessionRefresh",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 2,
Func: func(e *core.RequestEvent) error {
if e.Request.URL.Path == RefreshPath {
return e.JSON(http.StatusNotFound, map[string]string{
"error": "Продление сессии выключено",
})
}
return e.Next()
},
}
}
// SessionFromCookie перекладывает значение куки сессии в заголовок, которым
// хранилище читает предъявленную сессию.
//
// Куки хранилище не читает вовсе — только заголовок `Authorization`. Браузер же
// сам заголовка не шлёт, а своей страницы со скриптом у сервиса нет, поэтому
// сессия предъявляется кукой, а способ проверки остаётся один.
//
// Предъявленный заголовок побеждает: иначе браузер с сессионной кукой получал
// бы на собственных адресах хранилища не то, что предъявил.
//
// Слой стоит раньше проверки токена: тот идёт с приоритетом
// DefaultLoadAuthTokenMiddlewarePriority и к этому моменту заголовок должен
// быть на месте.
func SessionFromCookie() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberSessionFromCookie",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 1,
Func: func(e *core.RequestEvent) error {
if e.Request.Header.Get("Authorization") != "" {
return e.Next()
}
cookie, err := e.Request.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
return e.Next()
}
e.Request.Header.Set("Authorization", cookie.Value)
return e.Next()
},
}
}
+8
View File
@@ -44,6 +44,14 @@ type GetTranscribeJobResponse struct {
// сохранены — публичный контракт API объявлен необратимым. // сохранены — публичный контракт API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) { func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api") api := r.Group("/api")
// Оба адреса уходят за аутентификацию. Слой предъявления стоит перед
// проверкой и действует только здесь: собственная поверхность хранилища под
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
api.Bind(SessionFromCookie())
api.Bind(apis.RequireAuth())
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись // Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья, // раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
// поэтому предел тела равен потолку самой записи. // поэтому предел тела равен потолку самой записи.
+68 -15
View File
@@ -70,6 +70,50 @@ type testEnv struct {
handler *TranscribeHandler handler *TranscribeHandler
app core.App app core.App
journal *journalBuffer journal *journalBuffer
// session — значение сессии вошедшего. Приём и опрос закрыты за
// аутентификацией, и проверка, судящая их по существу, обязана предъявить
// сессию ровно так же, как это делает браузер.
session string
// account — учётная запись, которой выдана сессия. Нужна проверкам выхода.
account *core.Record
}
// serve шлёт запрос от имени вошедшего: сессия предъявляется кукой — тем же
// способом, каким её предъявляет браузер. Заголовок проверки не ставят: куку в
// него перекладывает слой предъявления, и подмена его здесь означала бы проверку
// не той цепочки.
//
// Проверки, судящие отказ без сессии, зовут `mux` напрямую.
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
e.mux.ServeHTTP(w, req)
}
// newTestAccount заводит учётную запись и выдаёт ей сессию.
//
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
// запросом больше нельзя — создание закрыто шагом схемы, и в этом весь смысл
// изменения. Прямое сохранение идёт мимо правил доступа так же, как идёт вход,
// когда учётную запись заводит само хранилище.
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
t.Helper()
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", "person@example.com")
record.Set("verified", true)
// Случайный пароль ставит и само хранилище, когда заводит запись по входу у
// провайдера: запись auth-коллекции без пароля не сохраняется, а войти по
// нему всё равно нельзя — парольный вход выключен шагом схемы.
record.SetRandomPassword()
require.NoError(t, app.Save(record))
token, err := record.NewAuthToken()
require.NoError(t, err)
return record, token
} }
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на // journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
@@ -147,7 +191,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
mux, err := r.BuildMux() mux, err := r.BuildMux()
require.NoError(t, err) require.NoError(t, err)
return &testEnv{mux: mux, handler: handler, app: app, journal: journal} account, session := newTestAccount(t, app)
return &testEnv{
mux: mux,
handler: handler,
app: app,
journal: journal,
session: session,
account: account,
}
} }
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске // createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
@@ -241,7 +294,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
req := createMultipartRequest(t, "sample.m4a", content) req := createMultipartRequest(t, "sample.m4a", content)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -304,7 +357,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, tc.req(t)) env.serve(w, tc.req(t))
require.Equal(t, http.StatusBadRequest, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
@@ -327,7 +380,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
req := createMultipartRequest(t, "empty.m4a", nil) req := createMultipartRequest(t, "empty.m4a", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -374,7 +427,7 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
req := createMultipartRequest(t, tc.fileName, []byte("запись")) req := createMultipartRequest(t, tc.fileName, []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -400,7 +453,7 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись")) req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -419,7 +472,7 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе")) req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code) require.Equal(t, http.StatusInternalServerError, w.Code)
@@ -459,7 +512,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись")) req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -482,7 +535,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе")) req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code) require.Equal(t, http.StatusInternalServerError, w.Code)
@@ -507,7 +560,7 @@ func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
req := createMultipartRequest(t, "sample.mp3", []byte("запись")) req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -528,7 +581,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
req := createMultipartRequest(t, "sample.mp3", content) req := createMultipartRequest(t, "sample.mp3", content)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -593,7 +646,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись")) req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -622,7 +675,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody) req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
@@ -642,7 +695,7 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody) req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
@@ -664,7 +717,7 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody) req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusNotFound, w.Code) require.Equal(t, http.StatusNotFound, w.Code)
+27
View File
@@ -50,6 +50,13 @@ func main() {
logger.Info("Configuration loaded successfully", "config_path", *configPath) logger.Info("Configuration loaded successfully", "config_path", *configPath)
} }
// Незаполненный вход роняет старт: подняться с молча выключенным входом
// значит остаться открытым наружу, и узнать об этом было бы неоткуда.
if err := cfg.Auth.Validate(); err != nil {
logger.Error("Unable to start with incomplete login settings", "error", err)
os.Exit(1)
}
// Загружаем переменные окружения из .env файла // Загружаем переменные окружения из .env файла
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
logger.Warn("Warning: .env file not found, using system environment variables") logger.Warn("Warning: .env file not found, using system environment variables")
@@ -172,6 +179,12 @@ func main() {
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда. // и второму серверу на нём взяться неоткуда.
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger) transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
AuthURL: cfg.Auth.AuthURL,
RedirectURL: cfg.Auth.RedirectURL,
ClientID: cfg.Auth.ClientID,
SecureCookie: cfg.Auth.SecureCookie,
}, logger)
// Сервер приезжает каналом, а не общей переменной: хук исполняется в // Сервер приезжает каналом, а не общей переменной: хук исполняется в
// горутине сервера, а читает его горутина остановки, и связи «произошло // горутине сервера, а читает его горутина остановки, и связи «произошло
@@ -207,6 +220,20 @@ func main() {
return err return err
}) })
// Настройки провайдера приводятся к конфигу при каждом подъёме:
// применённый шаг схемы не переписывается, и секрет, положенный
// однажды шагом, не пережил бы ротации.
if err := pbrepo.ApplyProviderSettings(storage, pbrepo.ProviderSettings{
AuthURL: cfg.Auth.AuthURL,
TokenURL: cfg.Auth.TokenURL,
UserInfoURL: cfg.Auth.UserInfoURL,
ClientID: cfg.Auth.ClientID,
ClientSecret: cfg.Auth.ClientSecret,
}); err != nil {
return fmt.Errorf("failed to apply provider settings: %w", err)
}
authHandler.Register(se.Router)
transcribeHandler.Register(se.Router) transcribeHandler.Register(se.Router)
se.Router.GET("/health", func(e *core.RequestEvent) error { se.Router.GET("/health", func(e *core.RequestEvent) error {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-12
@@ -0,0 +1,273 @@
## Context
Сегодня HTTP API открыт наружу без проверки — так записано первой строкой модели
угроз. Приглашение второго человека упирается в это: у записей нет владельца, а
подобранный идентификатор задачи отдаёт чужую расшифровку.
Решение от 2026-08-11 (`ADR-2026-08-11-pocketbase-storage-with-admin-panel`)
назвало способ: **ответ провайдера разбирает хранилище, а не наш код**. У
коллекции пользователей включается провайдер `oidc` с адресами Authelia, учётные
записи заводятся сами, и панель их видит. Проверено на версии 0.39.10 —
`docs/research/pocketbase.md`, раздел «Пользователи — только те, кого туда
положат».
**Способ остаётся верным, но его механика уже проверена по исходникам
библиотеки, и три ожидания постановки она не подтверждает.** Проверено чтением
`pocketbase@v0.39.10`:
1. **Куки библиотека не читает вовсе.** Сессию она берёт единственным способом —
заголовком `Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
Критерий приёмки задачи написан про куку.
2. **Эндпоинта выхода библиотека не приносит.** Список её адресов
аутентификации — `auth-methods`, `auth-refresh`, `auth-with-password`,
`auth-with-oauth2`, `request-otp`, `auth-with-otp`, восстановление пароля,
подтверждение почты и смена почты (`apis/record_auth.go`). Выхода среди них
нет.
3. **Браузерный вход по редиректу библиотека своим не приносит.** Она приносит
обмен уже полученного кода: `POST /api/collections/{c}/auth-with-oauth2`
требует `provider`, `code`, `codeVerifier` и `redirectURL`. Её собственный
`/api/oauth2-redirect` служит другому: он ищет клиента realtime-подписки по
параметру `state` и отдаёт код туда (`apis/record_auth_with_oauth2_redirect.go`)
— это механика её JS-клиента с всплывающим окном, а не серверный вход.
Отсюда объём: инициировать вход, принять возврат и завести куку — наш код.
Разбор ответа провайдера, заведение учётной записи и связь с внешним провайдером
остаются за хранилищем, как и решено. Решение 2026-08-11 не пересматривается.
## Goals / Non-Goals
**Goals:**
- запрос к приёму записи и к опросу готовности без сессии получает отказ и
ничего не заводит;
- вход идёт у Authelia по OIDC, учётные записи заводятся сами;
- выход закрывает доступ немедленно, а не по истечении срока;
- сессия переживает выкладку;
- проба здоровья и метрики остаются открытыми.
**Non-Goals:**
- владелец у записи и сужение выборки по нему — задача `record-ownership`;
- вход для программ по личным токенам — отдельная цель роадмапа. Внешняя
программа, ходившая в API анонимно, этим изменением ломается намеренно, и
замены ей здесь не появляется;
- белый список Telegram — живёт до `telegram-account-link`;
- панель администратора — в неё провайдер не пускает, наружу её закрывает
обратный прокси;
- своя страница входа со скриптом: у сервиса нет фронтенда, и заводить его ради
входа незачем.
## Decisions
### Сессия предъявляется кукой, а заголовок остаётся внутренним
**Выбрано:** наш обработчик возврата ставит куку `HttpOnly`, `Secure`,
`SameSite=Lax` со значением, выданным хранилищем. Промежуточный слой перед
проверкой перекладывает значение куки в заголовок `Authorization`, если заголовка
нет. Дальше работает штатная проверка библиотеки.
Человек увидит обычный вход: перешёл, авторизовался у Authelia, вернулся —
работает. Ни строки скрипта на его стороне.
Отвергнуто:
- **заголовок `Authorization` как единственный способ.** Это механика библиотеки
и путь наименьшего кода, но браузер такой заголовок сам не шлёт: понадобился
бы свой фронтенд, который держит значение и подставляет его. Фронтенда у
сервиса нет, а заводить его ради входа — работа шире задачи. Критерий приёмки
задачи вдобавок написан про куку;
- **своя таблица сессий.** Даёт полный контроль над выходом и сроком, но заводит
второй способ делать то, что хранилище уже делает, — и второй дом для факта
«кто вошёл». Отвергнуто по концептуальной целостности.
Заголовок при этом остаётся рабочим: его требуют собственные адреса
аутентификации хранилища, и глушить их значит ломать библиотеку изнутри. Это
осознанно оставленная вторая дверь, и она названа в спеке.
### Форма решения выбрана из трёх, а не из одной
Прежде трёх решений ниже — выбор самой формы. Рассматривались три.
**A — свой тонкий слой входа поверх хранилища.** Выбрана. Наш код ведёт флоу и
ставит куку, обмен кода и заведение учётной записи остаются за хранилищем.
Цена: сверка состояния и установка куки — наша ответственность, то есть ошибки
в чувствительном месте наши.
**B — вход целиком на обратном прокси.** Authelia стоит перед сервисом и не
пускает неузнанные запросы, приложение доверяет заголовку от прокси. Нашего кода
почти ноль. Отвергнуто по трём причинам сразу: при прямом обращении к порту
заголовок подделывает кто угодно в той же сети, а сервис не имеет способа
отличить прокси от постороннего; учётные записи в панели не появляются вовсе, а
решение 2026-08-11 требует обратного; вход для программ по личным токенам из
этой формы не вырастает — его пришлось бы делать заново и мимо.
**C — фронтенд и штатный клиент хранилища.** Своя страница, всплывающее окно,
подписка, значение сессии в хранилище браузера. Всё штатно для библиотеки.
Отвергнуто: у сервиса нет фронтенда, и заводить его ради входа — работа шире
задачи; значение сессии становится доступно скриптам страницы, то есть XSS
уносит сессию целиком, тогда как кука с запретом чтения скриптом этого не даёт.
### Вход и возврат ведёт наш код, разбор ответа — хранилище
**Выбрано:** три своих адреса — начало входа, возврат от провайдера, выход.
Начало входа заводит `state` и PKCE-verifier, кладёт их во временную куку и
уводит человека на `authURL` провайдера. Возврат сверяет `state`, а код отдаёт
хранилищу вызовом его же обмена — тем, что стоит за `auth-with-oauth2`.
**Обмен кода библиотека наружу не отдаёт** — он живёт неэкспортированной
функцией за собственным адресом хранилища. Решением владельца от 2026-08-12
обработчик возврата зовёт **этот адрес внутри процесса**, через роутер
хранилища, а не по сети.
Цена названа и принята: получается петля «наш обработчик → наш роутер → наш
обработчик», ответ разбирается текстом, а типизированная ошибка теряется.
Взамен решение 2026-08-11 соблюдается дословно — разбор ответа провайдера
остаётся за хранилищем, и учётные записи видны в панели.
Отвергнуто:
- **собрать обмен своими руками** из кусков, которые библиотека всё же отдаёт.
Прямой код без петли, но разбор ответа провайдера переезжает к нам — это
пересмотр решения 2026-08-11 отдельным ADR, и владелец его не выбрал;
- **всплывающее окно и realtime-подписка**, как делает JS-клиент библиотеки.
Работает без нашего кода вовсе, но требует того самого фронтенда и держит
открытым realtime-соединение ради одного входа.
### Кого пускать, решает провайдер, а не сервис
Решением владельца от 2026-08-12 сервис своей проверки допуска **не делает**:
кто допущен, определяет правило Authelia на этого клиента. Всякий, кого
провайдер пропустил, получает учётную запись и доступ.
Цена принята и обязана быть записанной: правило живёт вне репозитория, в
настройках выкладки, и сервис на него полагается так же, как полагается на
обратный прокси в части панели администратора. Настроенный слишком широко
клиент открывает сервис всем, у кого есть учётная запись в общей Authelia, — и
проверить это по коду нельзя. Строка об этом идёт в `docs/security.md`, раздел
«Что разграничивает доступ».
Отвергнуто: **проверка группы своим кодом** — защита стояла бы в сервисе и не
зависела от настройки контура, но владелец выбрал не заводить второе место, где
решается допуск.
### Выход обесценивает выданные сессии, а не только убирает куку
**Выбрано:** выход обновляет ключ токенов учётной записи
(`Record.RefreshTokenKey()` плюс сохранение) и убирает куку. Подпись сессии
считается от этого ключа, поэтому все прежние значения перестают проходить
разом.
Отвергнуто:
- **только уборка куки.** Унесённое значение продолжало бы открывать доступ до
истечения срока — то есть выход не закрывал бы доступ, а делал вид;
- **чёрный список выданных значений.** Даёт точечный выход одной сессии, но
требует своей таблицы и её чистки; при одном человеке и одном браузере это
цена без покупателя.
Цена выбранного названа прямо: выход закрывает **все** сессии учётной записи, а
не только текущую. При сегодняшнем числе пользователей это незаметно, и
переделка, когда станет заметно, — чёрный список из отвергнутого варианта.
### Сессия переживает перезапуск сама
Проверено по исходникам: подпись считается от секрета коллекции
(`Collection().AuthToken.Secret`) и ключа записи, оба лежат в базе
(`core/record_query.go`, `FindAuthRecordByToken`). Значит требование выполняется
устройством хранилища, и нашей работы здесь нет — есть проверка тестом.
### Настройки провайдера приводятся к конфигу при каждом запуске
**Выбрано:** шаг схемы включает провайдера с пустыми значениями, а адреса,
идентификатор клиента и секрет проставляются при подъёме сервиса из конфига.
Причина в инварианте: **применённый шаг схемы не переписывается**. Проставь
секрет однажды шагом — и ротация секрета в конфиге до хранилища не доедет вовсе,
вход сломается после смены ключа, а починить это можно будет только руками в
панели.
Отвергнуто:
- **секрет в шаге схемы.** Разбито инвариантом выше;
- **настройка руками в панели.** Работает, но не воспроизводится: поднятый с
нуля сервис оказывается без входа, и знание живёт в голове владельца.
### Что изменило ревью кода
Три решения приняты владельцем 2026-08-12 уже после того, как код был написан:
ревью нашло, что заявленное поведение не работает.
**Продление сессии выключено.** Хранилище выдаёт сессию продлеваемой, и
предъявитель менял своё значение на новое бессрочно, никуда не входя. При живом
продлении семисуточный срок не значил ничего — а он объявлен единственным
каналом, которым отзыв доступа у провайдера доходит до сервиса. Цена: вход раз в
семь суток. Отвергнуто: сверяться с провайдером по расписанию (новая связь с
Authelia и обработка её недоступности — работа шире задачи) и принять как есть
(тогда паспорт теряет способ остановить того, кто тратит слишком много).
**Файл записи открыт вошедшим.** Пометка поля защищённым сама по себе закрыла
файл вообще для всех, кроме владельца панели: защищённый файл судится ещё и
правилом просмотра коллекции, а незаданное правило означает «только
суперпользователь». Назначено правило для всякого узнанного. Отвергнуто:
оставить файл только панели — тогда задача про прослушивание записи начинается с
того же вопроса.
**Форма адреса провайдера проверяется на старте.** Непустая, но негодная строка
проходила проверку конфига и отвергалась хранилищем позже — из хука подъёма, до
регистрации пробы здоровья. Сервис падал целиком, вместе с ботом и воркерами, а
у владельца не было даже кода состояния. Отвергнуто: поднимать пробу здоровья
раньше настройки провайдера — это завело бы состояние «сервис жив, вход сломан»,
которого спека не описывает.
## Risks / Trade-offs
- **Секрет клиента появляется в новом месте — в базе.** → Инвариант проекта
запрещает секрету попадать в git, в лог, в ответ и в `error_text`; база в этом
перечне не значится, и запрета не нарушает. Но место новое, и модель угроз
обязана его назвать: чтение файла базы теперь равносильно чтению секрета
клиента. Пишется в `docs/security.md` этой же задачей.
- **Ломается внешняя программа, ходившая в API анонимно.** → Ломка намеренная и
объявлена в предложении: это и есть предмет задачи. Замены для программ
(личные токены) в этом изменении нет — она отдельной целью.
- **Вторая дверь: заголовок `Authorization` остаётся принимаемым.** → Он
предъявляет ту же сессию и той же проверке, поэтому обхода не даёт. Но это
второй способ войти, и в спеке он назван, чтобы не был обнаружен ревью как
находка.
- **Выход закрывает все сессии учётной записи.** → Названо решением выше, цена
принята.
- **PKCE-verifier и `state` живут во временной куке.** → Кука ставится на время
входа, `HttpOnly` и `SameSite=Lax`, и убирается на возврате. Хранить их в
памяти процесса нельзя: выкладка посреди входа роняла бы вход.
- **Признак `Secure` закрывает локальный запуск.** → Браузер не сохранит такую
куку по `http://localhost`, и вход перестанет работать у того, кто поднимает
сервис командой из раздела команд. Признак берётся из конфига с умолчанием
«включено», и расхождение образца называется строкой в
`docs/conventions/config.md`.
- **Коллекция пользователей остаётся умолчательной `users`.** → Своя коллекция
означала бы задание правил и способов входа с нуля вместо подчистки
умолчаний, а переезд позже — перевязку связей с провайдером и обесценивание
всех выданных сессий. Цена умолчательной: её заводит системный шаг библиотеки
с открытым созданием записи, и закрывать это приходится нам.
- **Проверить вход целиком без живой Authelia нельзя.** → Тесты закрывают
сверку `state`, отказ без сессии, выход и сохранность сессии; живой вход у
провайдера остаётся ручной проверкой владельца на выкладке. Это граница
покрытия, и она называется в докладе.
## Migration Plan
Шаг схемы включает провайдера у коллекции пользователей и накатывается при
подъёме, как и прежние шаги. Данных он не трогает: ни одной записи не
переписывается, учётные записи заводятся сами при первом входе.
Откат — прежний образ: шаг схемы обратим своим `down`, а до первого входа в
коллекции пользователей пусто.
Порядок выкладки: сперва завести клиента в Authelia и получить секрет, потом
положить его в конфиг на сервере, потом выкладывать. Обратный порядок поднимает
сервис с провайдером без секрета — вход не работает, а API уже закрыт.
## Open Questions
- Адрес возврата должен совпадать с тем, что записан клиенту в Authelia. Значение
выбирается при заведении клиента и попадает в конфиг; здесь оно не
фиксируется.
@@ -0,0 +1,54 @@
## Why
HTTP API открыт наружу без всякой проверки: кто угодно из интернета заводит
задачи расшифровки за наши деньги и читает чужие расшифровки, подобрав
идентификатор задачи. Сегодняшний периметр так и записан в модели угроз —
аутентификации не делает ни обратный прокси, ни само приложение.
Второго человека пригласить в сервис сейчас нельзя: это значит открыть ему всё,
что в сервисе уже лежит.
## What Changes
- Сервис узнаёт, кто к нему пришёл. Учётные записи заводит и проверяет внешний
провайдер — Authelia по OIDC; своей регистрации и своих паролей не заводим.
- **BREAKING** Приём записи и опрос готовности задачи требуют входа: запрос без
сессии получает отказ и не заводит задачу, а текста расшифровки не отдаёт.
Внешняя программа, ходившая в API без всякого входа, перестаёт работать.
- Появляются вход и выход: вход уводит человека к провайдеру и возвращает
обратно уже узнанным, выход закрывает доступ немедленно.
- Проба здоровья и метрики остаются открытыми и сессии не требуют: ни у пробы,
ни у сборщика метрик её нет. Наружу их закрывает правило обратного прокси —
это работа выкладки.
- Разграничения записей по владельцу здесь **нет**: после входа человек видит
ровно столько же, сколько видно сейчас.
## Capabilities
### New Capabilities
- `access`: кто пришёл в сервис и пускают ли его дальше — вход через внешнего
провайдера, чем предъявляется сессия, что её прекращает и какие адреса
остаются открытыми.
### Modified Capabilities
- `intake`: приём записи и опрос готовности задачи перестают быть доступны
анонимно — оба требуют узнанного отправителя.
- `storage`: ссылка на файл записи перестаёт быть правом пройти по ней — файл
отдаётся только узнанному отправителю.
## Impact
- Коллекция пользователей хранилища: включённый провайдер `oidc` с адресами
Authelia, идентификатором клиента и секретом; связь учётной записи с внешним
провайдером хранилище ведёт своей служебной коллекцией.
- `POST /api/audio` и `GET /api/status/{id}` — публичный контракт HTTP API
объявлен проектом необратимым, и здесь он меняется: у обоих появляется отказ
без входа.
- Новые адреса входа, возврата от провайдера и выхода.
- Секция конфигурации под провайдера: адрес, идентификатор клиента, секрет.
Секрет попадает в настройки коллекции хранилища — это новое место, где он
живёт, и его надо назвать в модели угроз.
- `config.dist.toml` и разбор конфига.
- `docs/security.md`: первая строка периметра перестаёт быть верной.
- Панель администратора не трогается: в неё провайдер не пускает, и закрывает её
обратный прокси.
@@ -0,0 +1,313 @@
# Ревью кода: oidc-login — триаж
База диффа: `origin/master`, изменение целиком в рабочем дереве. Дата прогона: 2026-08-12.
---
## Что сделано по итогам (дописано оркестратором после отработки)
Все семь пунктов закрыты; три развилки решены владельцем 2026-08-12.
| Пункт | Исход |
|---|---|
| 1. Войти не может никто | Исправлено: `CreateRule = @request.context = "oauth2"` правкой неуехавшего шага. Оракул — `TestLoginCreatesAccountAndSession`: вход целиком через подставного провайдера, учётная запись заводится, сессия выдаётся |
| 2. Утечка обработчиков | Исправлено: роутер хранилища собирается один раз через `sync.Once`. Оракул — `TestCallbackDoesNotLeakHooks`: 20 возвратов не меняют длины очереди |
| 3. Файл не отдаётся вошедшему | **Решение владельца: открыть вошедшим.** Назначено `files.ViewRule = @request.auth.id != ""`, спека дополнена порядком «сессия → токен файла → ссылка». Оракул — `TestRecordFileNeedsSessionAndToken` |
| 4. Носитель состояния не убирался | Исправлено: уборка перенесена до записи ответа; все проверки файла судят по `w.Result()`, а не по живой карте заголовков |
| 5. Опечатка в `[auth]` роняет процесс | **Решение владельца: проверять форму адреса на старте.** `Validate` разбирает адреса и требует схему и хост. Оракул — `TestAuthConfigValidateRejectsMalformedURL` |
| 6. Отзыв доступа не доходит | **Решение владельца: выключить продление.** Заведён слой `BlockSessionRefresh`; спека и модель угроз дополнены. Оракул — `TestSessionRefreshIsClosed` |
| 7. Сердцевина не покрыта | Исправлено: заведён `login_test.go` (вход целиком, отказ обмена, утечка, продление, файл) и `internal/config/config_test.go`; закрыты ветки выхода |
Сверх семи, из срезанного потолком, исправлено там же, где окно закрывается мерджем:
- **G** — срок жизни сессии перенесён из шага схемы в приведение настроек при подъёме;
- **L** — откат шага больше не падает на валидации и не возвращает открытую регистрацию;
- **O**`docs/database.md` приведён к коду, коллекция `users` описана, три числа внесены в таблицу;
- **Q** — уровни журнала разведены по адресату, добавлено поле `capability`;
- **R**`auth.client_secret` внесён в перечень секретов и в инвариант `CLAUDE.md` с изъятием про базу;
- **N** — причина отказа провайдера приводится к перечню известных кодов;
- язык ответов пользователю переведён на русский.
**Не сделано намеренно, ушло в урожай:** `P` (настройка `docs/.docs.json` указывает на несуществующий каталог миграций — дефект гейта, не этого изменения), `M` (код провайдера в журнале запросов хранилища), `S` (начало входа собрано руками), `T`/`U` (рантбук выкладки), `V` (проверка конфига не в норме), гипотезы без пути и остаток пункта 4 (серверный учёт употреблённых состояний).
---
## Сводка
**Размер, сложность, метка.** Размер — крупное: 24 подзадачи в 6 группах, 5 слоёв кода, 8 узлов в «Затрагивает», 2 capability (одна ADDED целиком). Сложность — незнакомое: `docs/review.md`, «Триггеры метки», «Незнакомое здесь» называет вход через OIDC дословно первой строкой. **Метка `large`** (максимум по осям), **режим — по графу**, опиниативные проходы открыты.
**Состояние гейта: ЗЕЛЁНЫЙ.** Проверено собственным прогоном триажа, а не только отчётом прохода: `task gate BASE=origin/master` → exit 0, все девять шагов. Унаследованное замечание `tasks.py` (`any-audio-source`) шаг не роняет и к диффу отношения не имеет.
**Зелёный гейт здесь — часть находки, а не свидетельство.** Два теста в `internal/controller/http/auth_test.go` утверждают проверенным то, что не работает (пункты 3 и 4), и оба зелёные. Два пункта `tasks.md` — 5.5 и 5.8 — отмечены `[x]` за проверки, которых в файле нет.
### План разметки задачи с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/changes/oidc-login/specs/{access,intake,storage}/spec.md` | разбор | specs | **закрыта**, 5 находок (C, D, E, M, V) + 3 блока наблюдений |
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | **закрыта**, 3 находки (H, I, J) + отчёт гейта и `govulncheck` |
| conventions | `docs/conventions/{config,database,errors,logging}.md` | разбор | code | **закрыта**, 7 находок (A, L, O, P, Q, R + доля в B) |
| architecture | `docs/architecture.md`; источник `docs/passport.md` | доказательство | architecture | **закрыта**, 3 находки (B, G, S) + 5 пунктов «дешевле переделать» |
| security | `docs/security.md` | доказательство | adversary | **закрыта**, 6 находок (A, B, E, F, M, N) + 5 свойств без пути |
| operations | `docs/architecture.md` «Эксплуатация»; источник `docs/database.md` | доказательство | ops | **закрыта**, 3 находки (K, T, U) |
**Тем без отчёта нет.** Все шесть тем ядра вернули отчёты. `review-basics` не запускался — по решению `review-scope`: своих тем сверх ядра у проекта нет. Тем, унесённых непроверенными, нет.
**Сигнал о заниженной метке: не поступил, и провенанс у этого один.** `review-code` подал строку прямо: «метка `large` соответствует изменению, понижения не вижу». `review-basics` не запускался, поэтому второго независимого корректора метки у прогона не было — согласия двух проходов нет, есть отсутствие возражения от одного.
**Находок на входе:** 22 нумерованных (A–V) плюс 23 ненумерованных содержательных пункта (8 «поведение вне спеки», 5 «границы спеки», 5 «свойства без построенного пути», 5 «дешевле переделать») = **45 позиций**. **Осталось в основных секциях: 7** — 3 блокирующие и 4 «стоит исправить сейчас». Слито по причине 4 группы, понижено до гипотез 5, уехало в promote 6, выброшено как вкусовщина 3, срезано потолком с поимённым перечислением 13.
---
## Блокирует мердж
### 1. После выкладки войти не может никто, включая владельца — первый вход не заводит учётной записи
- Файл: `internal/adapter/repo/pocketbase/migrations.go:149`
- Severity: `critical` · Confidence: `high`
- Найдено проходами: code, adversary (2 прохода, оракула два разных; согласие приоритет повышает, `confidence` — нет)
- **Оракул — мой собственный, добыт на этом прогоне.** Тест против настоящего PocketBase с подставным провайдером OIDC (`httptest`, token + userinfo), запущен через `go test -overlay=…` без записи в дерево проекта:
```
users.CreateRule = <nil>
ИСХОД: код=401 обращений к токен-эндпоинту=1 учётных записей=0
тело={"error":"Login failed"}
ERROR Failed to exchange provider code error="storage rejected the exchange with code 403"
```
Причина изолирована тем же прогоном: с `CreateRule = @request.context = "oauth2"` результат `код=302 учётных записей=1 Location="/"`, при этом анонимный `POST /api/collections/users/records` по-прежнему получает `400`. Подтверждение по исходникам библиотеки: `apis/record_crud.go:230-232` (`!hasSuperuserAuth && collection.CreateRule == nil` → Forbidden); внутренний запрос обмена идёт без авторизации.
- Последствие: шаг схемы закрывает создание записи для всех, кроме суперпользователя, а запись при первом входе заводит именно внутренний запрос обмена. Ни одной записи `users` шаг схемы не создаёт, приём и опрос закрыты сессией. После выкладки HTTP-вход не работает ни у кого; жив только Telegram. Лечится руками в панели — ровно то, от чего задача уходила.
- Предложение: `users.CreateRule = ptr("@request.context = \"oauth2\"")` **правкой самого шага `up202608120001`**: он ещё не уезжал на сервер, и окно закрывается мерджем — после выкладки то же изменение потребует нового шага. **Не** чинить открытием `CreateRule = ""`: публичный `auth-with-oauth2` принимает `createData`, и всякий владелец учётной записи Authelia задаст поля новой записи сам.
- Действие: **инлайн**
### 2. Всякий анонимный запрос на возврат навсегда добавляет обработчики приложению и дёргает Authelia нашим секретом
- Файл: `internal/controller/http/auth.go:224-234`
- Severity: `critical` · Confidence: `high`
- Найдено проходами: architecture, adversary (`critical`), specs и code (`minor`) — 4 прохода, оракула два
- **Оракул — мой собственный, тот же прогон:**
```
OnModelAfterCreateSuccess: до=5 после 50 возвратов=55
OnModelAfterUpdateSuccess: до=6 после=106
обращений к провайдеру за 50 анонимных возвратов: 50
провайдер недоступен: обработчиков до=5 после 10 возвратов=15
```
Последняя строка важна: утечка происходит **раньше** сетевого обращения и работает при мёртвом провайдере. Путь анонимный: атакующий сам ставит себе куку `transcriber_login=S:V` и зовёт `/auth/callback?state=S&code=x` — сверка сравнивает две его же величины. Причина: `apis.NewRouter` (`apis/base.go:47,53`) зовёт `bindRealtimeEvents` и `bindUIExtensions`, которые вешают обработчики **на приложение** без поля `Id`; `hook.Bind` (`tools/hook/hook.go:64`) дописывает, а не заменяет.
- Последствие: рост линейный и не освобождается до перезапуска; каждое сохранение задачи конвейером проходит по всем накопленным замыканиям (замер adversary: 3000 хитов → 100 сохранений 3.86ms → 59.8ms, куча +5013 КиБ). Побочно каждый анонимный запрос гонит обмен к Authelia нашими `client_id`/`client_secret`.
- Предложение: строить роутер один раз (при подъёме либо `sync.Once`), держать `http.Handler` полем `AuthHandler`. Решение владельца о петле внутри процесса не пересматривается.
- Остаток, который правка не закрывает и который я не заказываю: анонимный запрос по-прежнему вызывает исходящее обращение к провайдеру. Ограничение числа запросов в scope изменения не входит; названо, чтобы не потерялось.
- Действие: **инлайн**
### 3. Файл записи не отдаётся ни одному вошедшему — сценарий дельты не исполняется, а `docs/security.md` уже утверждает обратное
- Файл: `internal/adapter/repo/pocketbase/migrations.go:168-179`; тест `internal/controller/http/auth_test.go:405-431`; `docs/security.md:112-114`; `openspec/changes/oidc-login/tasks.md:68`
- Severity: `major` · Confidence: `high`
- Найдено проходами: specs, code, adversary
- **Оракул — мой собственный, тот же прогон:**
```
files.ViewRule = <nil>
вошедший кукой → 404
вошедший заголовком → 404
файловый токен получен: код=200 непусто=true
вошедший файловым токеном → 404
аноним → 404
```
Причина: `Protected=true` включает проверку по файловому токену **и** по `ViewRule` коллекции (`apis/file.go:109-134`); `ViewRule` у `files` не назначался ни одним шагом → `nil` → доступ только суперпользователю (`core/record_query.go:606-608`).
- Последствие: сценарий дельты `storage` «GIVEN забирающий предъявил сессию THEN приходит тот же файл» не исполняется. Пункт `tasks.md` 5.8 («без сессии отдаёт отказ, **а с сессией — тот же файл**») отмечен сделанным, а тест проверяет только отказ анониму. `docs/security.md:113` уже переписан утверждением «пройти по ссылке теперь можно только с сессией»: сегодня это ложно. Заведённая задача про прослушивание записи упрётся сюда и, вероятнее всего, «починит» снятием `Protected`, вернув «знание ссылки = доступ».
- Действие: **развилка**
**Вопрос владельцу.** Файл записи защищён так, что его не получает никто, кроме владельца панели. Что делаем:
1. назначить `files.ViewRule` для вошедших и описать в спеке шаг «сессия → файловый токен → ссылка» (цена: правка шага схемы + новый абзац нормы + тест; окно на правку неуехавшего шага закрывается мерджем);
2. переписать требование как «файл виден только владельцу в панели», снять сценарий из дельты `storage` и поправить `docs/security.md` (цена: правка нормы, задача про прослушивание записи начинается с этого же вопроса);
3. оставить как есть и записать расхождение (цена: норма и код разошлись сознательно, следующий проход найдёт то же самое).
---
## Стоит исправить сейчас
### 4. Носитель состояния входа живёт 10 минут вместо одного входа, и стерегущий его тест зелёный ложно
- Файл: `internal/controller/http/auth.go:131` (defer), `293-303`; тест `internal/controller/http/auth_test.go:272`
- Severity: `major` · Confidence: `high`
- Найдено проходами: specs, code (сведено с находкой «одноразовость состояния не реализована ничем» — причина одна: единственным механизмом одноразовости была уборка куки)
- **Оракул — мой собственный, тот же прогон, настоящий сервер против recorder'а:**
```
НАСТОЯЩИЙ СЕРВЕР: код=401 Set-Cookie=[]
RECORDER rec.Header()=[transcriber_login=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax]
RECORDER rec.Result().Header=[]
УСПЕХ: код=302 Location="/" Set-Cookie=[transcriber_session=…; Max-Age=604800; HttpOnly; Secure; SameSite=Lax]
```
На успешной ветке уборки состояния тоже нет — в ответе только кука сессии. Причина: `e.SetCookie` правит карту заголовков, а `defer` исполняется **после** `e.JSON`/`e.Redirect`, которые уже позвали `WriteHeader`. `net/http` при `WriteHeader` фиксирует снимок; `httptest.ResponseRecorder` наоборот — `Header()` отдаёт живую карту, а снимок лежит в `snapHeader`, который читает `Result()`.
- Последствие: спека требует «MUST убираться на возврате — и на успешном, и на отказном»; носитель не убирается ни там, ни там и остаётся годным 10 минут. Повторно поданный URL возврата проходит сверку и уходит в обмен; отказ наступает только потому, что код у провайдера одноразовый — гарантия перенесена на внешнюю систему, чего спека не допускает. Тест утверждает обратное и проходит, потому что читает `w.Header()`.
- Предложение: убирать куку **до** записи ответа (как уже сделано в `Logout`); всем тестам этого файла судить по `w.Result()`.
- Остаток: `tasks.md` 5.5 обещает ещё и «повторный возврат с уже употреблённым состоянием», то есть учёт употреблённых состояний. Уборка куки закрывает переигрывание тем же браузером, но не учёт как таковой. Нужен ли учёт — вопрос владельцу, отдельно от этой правки; в код его сейчас не заказываю.
- Действие: **инлайн**
### 5. Опечатка в `[auth]` кладёт весь сервис детерминированно, и `/health` в этот момент ещё не зарегистрирован
- Файл: `main.go:220-234`; `internal/config/config.go:69-90`
- Severity: `major` · Confidence: `high`
- Найдено проходом: ops
- Оракул: эксперимент прохода — `ApplyProviderSettings` с непустым, но негодным URL → `oauth2: (providers: (0: (authURL: must be a valid URL; tokenURL: …).).)`. Проверено мной по коду: `AuthConfig.Validate` (`config.go:69-90`) сверяет **только непустоту** шести ключей; `ApplyProviderSettings` зовётся внутри хука `OnServe` **выше** регистрации `/health` и `/metrics` (`main.go:227-234` против `main.go:239+`), а ошибка хука прерывает `apis.Serve` до открытия порта (`apis/serve.go:216-269`). Далее общий shutdown останавливает бот и все три воркера.
- Последствие: пробел от шаблона или отсутствующая схема в адресе роняет сервис целиком при каждом перезапуске, и у владельца нет даже кода состояния — только текст в журнале контейнера. Это первая выкладка этой секции конфига, шесть новых ключей.
- Действие: **развилка**
**Вопрос владельцу.** Негодный адрес провайдера сегодня валит процесс молча. Что делаем:
1. проверять форму URL в `Validate()` — отказ переезжает на старт, называет ключ поимённо и виден в журнале сразу (цена: три строки, поведение «не поднимаемся с кривым входом» сохраняется);
2. регистрировать `/health` и `/metrics` **до** `ApplyProviderSettings` — сервис поднимается и честно отвечает о своём состоянии (цена: появляется состояние «сервис жив, вход сломан», которого спека не описывает);
3. и то и другое.
### 6. Отзыв доступа в Authelia не доходит до сервиса никогда: предъявитель продлевает сессию сам
- Файл: `internal/adapter/repo/pocketbase/migrations.go:126-130` (комментарий); `openspec/changes/oidc-login/specs/access/spec.md:185-188`; `docs/security.md:145`
- Severity: `major` · Confidence: `high`
- Найдено проходом: adversary
- **Оракул — мой собственный, тот же прогон:**
```
auth-refresh #1 → код=200 новый токен непуст=true
auth-refresh #2 → код=200 новый токен непуст=true
auth-refresh #3 → код=200 новый токен непуст=true
продлённое значение на /api/status → 404 (401 значило бы, что не работает)
```
Замер adversary добавляет растущий `exp` (13:32:59 → 13:33:00 → 13:33:01 → 13:33:03) и claim `refreshable=true`.
- Последствие: спека дельты `access` объявляет срок сессии «единственным, что доносит до сервиса отзыв доступа у провайдера», и на этом утверждении стоит ссылка паспорта на отзыв в Authelia как на способ остановить перерасход. Канала нет: предъявитель одного живого значения продлевает себе доступ бессрочно, никуда не входя. Аноним так не может — нужен живой токен.
- Действие: **развилка**
**Вопрос владельцу.** Что делаем с продлением сессии:
1. выключить продление (закрыть `auth-refresh` для `users`) — отзыв начинает доходить за семь суток, как обещает норма (цена: человек перевходит раз в неделю);
2. сверяться с провайдером по расписанию (цена: новая связь с Authelia, обработка её недоступности, вне текущего scope);
3. принять как есть и **сейчас же** убрать из `specs/access/spec.md`, `docs/security.md` и комментария шага схемы утверждение про канал отзыва (цена: паспорт теряет способ остановить перерасход, и это надо записать явно).
При любом варианте утверждение о канале отзыва сегодня ложно — правка нормы обязательна во всех трёх.
### 7. Сердцевина входа не исполнялась ни одним тестом: обмен кода, выдача куки сессии и проверка конфига
- Файл: `internal/controller/http/auth.go:199-252, 269-279`; `internal/config/config.go:69-90`; `internal/controller/http/auth.go:174-195`
- Severity: `major` · Confidence: `high`
- Найдено проходами: autotests, specs (сведены три находки: покрытие `exchange` и `setSessionCookie`, непокрытая `AuthConfig.Validate`, непокрытые ветки отказа `Logout` — причина одна: проверки останавливаются раньше сердцевины)
- **Оракул — мой собственный прогон покрытия:**
```
auth.go:199 exchange 0.0%
auth.go:269 setSessionCookie 0.0%
auth.go:128 Callback 54.5%
auth.go:174 Logout 63.6%
config.go:69 Validate 0.0% (у пакета internal/config нет файла тестов вовсе)
```
- Последствие: единственный код, который меняет код провайдера на сессию, и код, который выдаёт сессию браузеру, не проверены ни на успех, ни на отказ. Спека объявляет имя `transcriber_session` нормативным именно потому, что «тест, ставящий и читающий одно и то же имя, этого не замечает» — потеря `HttpOnly`/`Secure`/`SameSite`, смена имени или срока пройдут гейт зелёными. Класс не новый: `docs/review.md`, журнал, записи 2026-08-10 («тесты http-обработчика ни разу не были зелёными») и 2026-08-11 («проверка приёма не могла упасть») — тот же род, третье появление.
- Предложение: поднять подставного провайдера `httptest` и пройти `Callback` до `302` + куки, судя по `w.Result().Cookies()`, сверяя литерал имени, флаги и `MaxAge`; завести файл тестов `internal/config`; закрыть обе ветки отказа `Logout`.
- Действие: **инлайн**
---
## Гипотезы без доказательства
- **Чужая страница гасит сессию.** `POST /auth/logout` сессии не требует и на кросс-сайтовом запросе отвечает `200` с `Set-Cookie Max-Age=-1`. Браузера в прогоне нет, применение `SameSite=Lax` к POST не проверялось. Confidence `low``minor` (adversary).
- **Две учётные записи провайдера с одной почтой сливаются в одну нашу.** Обмен ищет по `sub`, не найдя — по почте. Требует, чтобы Authelia выдала двум субъектам один адрес; живого провайдера нет. Confidence `medium`, без оракула → выше `major` не поднимается и в основные секции не идёт (adversary).
- **Претензия `picture` тянет до 32 МиБ на вход.** `MappedFields.AvatarURL` заставляет библиотеку скачать URL из ответа провайдера. Confidence `low``minor` (adversary).
- **Анонимный `POST /api/collections/users/request-verification` заставляет сервис слать почту.** Почта не настроена, потолка запросов нет. Confidence `medium``minor` (adversary).
- **Вошедший читает, правит и удаляет свою запись `users`** (системные правила `id = @request.auth.id` оставлены), тогда как `docs/security.md` утверждает, что правила коллекций пусты и отдают `403`. Своим прогоном не проверял — бюджет попытки израсходован на пункты 1–4, 6, 7 (adversary).
## Promote candidates
- **Проверка ответа судит по `w.Result()`, а не по `w.Header()`.** Ровно этим различием держался ложно зелёный тест пункта 4. Место — `docs/review.md`, «Типовые узлы», абзац про способность проверки упасть: род механизируем grep'ом и уже дал дефект.
- **`docs/.docs.json` объявляет механизированную сверку миграций, которой нет.** Ключ `"migrations": "migrations"`, а `check_migrations` фильтрует изменённые файлы по префиксу `migrations/`; такого каталога в репозитории нет (`git ls-files | grep -c "^migrations/"``0`), шаги схемы лежат в `internal/adapter/repo/pocketbase/`. Шаг гейта зелен при изменённом `migrations.go` и нетронутом `docs/database.md`. Правило есть, механизации нет: `"migrations": "internal/adapter/repo/pocketbase"` либо снять пометку «механизировано» в `docs/conventions/README.md`.
- **Откат бинаря не откатывает шаг схемы** — назвать в `CLAUDE.md`, «Необратимое», рядом с «применённой миграцией» (эксперимент ops: два прогона `New()` разных ревизий над одним каталогом, `files.file.Protected=true` сохраняется).
- **Новый секрет `auth.client_secret` не назван ни в перечне секретных полей `docs/conventions/config.md:94-96`, ни в инварианте `CLAUDE.md`.** Перечень поимённый и закрытый — это и делает его правилом.
- **Ввод пользователя в журнале приводится к закрытому перечню, а не пишется как есть** — обобщение приёма задачи `no-user-filename-in-log`. Повод: `providerError` из query уходит в `logger.Warn` целиком (падающий тест adversary: 204806 байт запроса → 204902 байта журнала).
- **Имя провайдера `oidc` — это значение в связи учётной записи с провайдером.** Смена имени после выкладки отвяжет всех заведённых людей. `CLAUDE.md`, «Необратимое», знает имя ключа конфига, но не знает имени провайдера.
## Границы покрытия
### План: темы, глубины, дома
| тема | дом | глубина | закрыта |
|---|---|---|---|
| requirements | дельта-спеки `access`, `intake`, `storage` | разбор | specs |
| autotests | `CLAUDE.md`, «Гейт» | — | autotests |
| conventions | `docs/conventions/{config,database,errors,logging}.md` | разбор | code |
| architecture | `docs/architecture.md`; источник `docs/passport.md` | доказательство | architecture |
| security | `docs/security.md` | доказательство | adversary |
| operations | `docs/architecture.md` «Эксплуатация»; источник `docs/database.md` | доказательство | ops |
Тем без дома нет. Тем без отчёта нет.
### Что запускалось и что нет
- Запущены на метке `large`, режим «по графу»: `specs`, `code`, `architecture`, `adversary`, `ops`, `autotests`.
- `basics` не запускался: решение `review-scope` — своих тем сверх ядра нет. Это решение, а не бюджет.
- Триаж запускал сам: `task gate BASE=origin/master` (exit 0), покрытие `internal/controller/http` и `internal/config`, шесть собственных тестов-оракулов через `go test -overlay=…` (в дерево проекта не писал), чтение исходников PocketBase v0.39.10 и `docs.py`.
### Чего запущенные проходы не могли проверить в принципе
- Живой вход у настоящей Authelia не воспроизводился ни одним проходом и мной: провайдера нет, поднять нечем. Всё, что известно о протоколе, получено против подставного провайдера и исходников библиотеки.
- Поведенческая верификация живым запуском сервиса не проводилась: адаптер Telegram роняет старт при негодном токене, а боевым токеном запускаться запрещено (`CLAUDE.md`, «Запреты»).
- Поведение браузера с куками — применение `SameSite`, приём `Set-Cookie` кросс-сайтом — не проверялось: браузера в прогоне нет.
- Панель `/_/` в тестовом роутере отсутствует (её вешает `apis.Serve`); закрывает её обратный прокси, то есть выкладка, а она вне модели.
- `govulncheck` дал две уязвимости (GO-2026-6061 grpc, GO-2026-5764 aws eventstream/s3), обе унаследованы от `origin/master`, в цепочке распознавания, не в этом коде.
- Замер утечки обработчиков сделан на 50 и 3000 итерациях в тесте; поведение под настоящим потоком не замерялось ничем.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке». Списки не сливаются: при следующем промахе первый вопрос — «не тот ли это класс, который мы перестали проверять».
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки — проект работает на единицах записей в день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe` — проверки приёма получают длительность от подставного источника; своего теста у `adapter/metaviewer/ffmpeg` нет (`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`).
**Сверх проектного перечня — общее:** история инцидентов, поведение под реальным потоком, поведение внешних систем в их версиях (здесь — конкретной Authelia владельца и её правила на этого клиента), завязка потребителей на текущее поведение и вопрос «а нужна ли эта функциональность вообще».
### Каких документов проекта не хватило
- `docs/review.md`, «Типовые ложноположительные»: раздел есть и непуст (4 пункта), но **ни один не относится к области этого изменения** — все четыре про конвейер, очередь и открытый HTTP. Отсев для темы входа и веб-поверхности шёл по общим критериям, проектных ложноположительных этой области я не знал.
- `docs/review.md`, «Вопросы по темам»: вопросов по теме входа нет — раздел писан до появления этой поверхности. Вопросы `security` про журнал и метки применялись, вопросы про конвейер неприменимы.
- `docs/conventions/web-ui.md` существует (104 строки), требование к языку пользовательского текста в нём есть; конвенции по форме HTTP-ответов входа (коды, тело отказа) в нём нет — поэтому «Login failed» судилось только по требованию русского языка, а форма ответа не судилась ничем.
- Прочих пробелов проходы не заявляли; `CLAUDE.md` с разделом инвариантов, `docs/security.md`, `docs/architecture.md`, `docs/database.md`, `docs/passport.md` и четыре конвенции были на месте и использовались.
### Потолки проходов
- `code`: 4 из 4 — срез сработал. За срезом остались два рода, названы самим проходом: (1) язык пользовательского текста на новой публичной поверхности («Login failed», «Logout failed» по-английски против `web-ui.md`); (2) продолжение известных «Расхождений» новым кодом — `msg` предложением, «failed to» в обёртках, третья точка трансляции доменной ошибки в ответ.
- `architecture`: 3 из 3 — потолок выбран полностью, за срезом ничего не заявлено.
- `specs`, `adversary`, `ops`, `autotests`: **свои потолки не сообщили.** Это находка о прогоне: сколько находок каждый показал против своего лимита и что осталось за срезом, установить нечем. Из четверых пришло 5, 6, 3 и 3 находки — то есть по крайней мере `adversary` шёл близко к типичному лимиту, и молчание о срезе здесь дороже всего.
### Срезано потолком триажа — названо поимённо
Тринадцать позиций с оракулами не попали в секции 1–2 и **к правке не заказаны**. Строки ниже — не задание; они здесь потому, что ничего не выбрасывается молча.
1. **G** (`major`, architecture): `SessionDuration` питает две точки разной обратимости — `AuthToken.Duration` в применяемом однажды шаге схемы и `MaxAge` куки, перечитываемый каждый подъём. Первая же правка константы уедет только в куку. Оракул — инвариант `CLAUDE.md` «Миграция, уехавшая на сервер, не переписывается» и собственный довод `design.md`. Правка (перенести `AuthToken.Duration` в `ApplyProviderSettings`) стоит трёх строк **сейчас** и требует нового шага схемы **после** выкладки: окно закрывается мерджем. Первый кандидат на восьмое место.
2. **M** (`minor`): код провайдера оседает в журнале запросов хранилища на пять суток. Проверено мной по исходникам: `activityLogger` пишет `event.Request.URL.RequestURI()` (со строкой запроса) полем `url` (`apis/middlewares.go:391,422`), ретеншен `MaxDays: 5` (`core/settings_model.go:158`), проект его не переопределяет. Спека требует «код MUST не попадать в журнал»; наш `slog` чист, и тест смотрит только в него.
3. **N** (`minor`): аноним пишет в журнал контейнера мегабайты (`?error=<1 МиБ>``Warn` целиком; падающий тест adversary: 204806 → 204902 байта). Журнал — единственное место наблюдения двух инвариантов о молчаливой потере задачи.
4. **L** (`minor`): `down202608120001` падает на валидации — проверено моим прогоном: `Save(users)` с `Duration=0``authToken: (duration: cannot be blank.)`. Путь «шаг обратим своим down» из `design.md` не работает, снятие `Protected` не выполняется вовсе, а сам `down` возвращает `CreateRule = ""` — открытую регистрацию, то самое, что чинит `up`.
5. **O** (`minor`): `docs/database.md:98-101` противоречит коду («поле файла не помечено защищённым»), коллекция `users` не описана, три новых числа (7 суток, 10 минут, 15 секунд) не попали в таблицу «Настройки с числовым значением». Оракул — `docs/conventions/database.md`, «Прочее».
6. **Q** (`minor`): уровни журнала не по адресату — отказ человека у Authelia даёт `WARN`, возврат по старой ссылке `ERROR`. Оракул — `docs/conventions/logging.md`, «Уровни», дословно. Плюс ни одна новая запись не несёт поля `capability`.
7. **S** (`minor`): начало входа собрано руками поверх того, что библиотека экспортирует (`InitProvider`, `BuildAuthURL`, `PKCE`) — протокол разрезан пополам, `auth_url` и `client_id` получают второго потребителя мимо настроек коллекции.
8. **U** (`minor`): пустая секция `[auth]` роняет сервис целиком, тогда как пустой токен бота лишь деградировал до «работает без Telegram». Асимметрия осознанная, но в рантбуке выкладки не названа.
9. **V** (`minor`): новое безусловное условие отказа старта по шести ключам живёт в `tasks.md` и конвенции, но не в норме.
10. **Поведение вне спеки** (8 пунктов от `specs`, ни один не заказан): `stateCookieMaxAge` 10 минут; редирект успешного входа на `/`; выход без сессии отвечает `200`; отказ загрузки записи при выходе оставляет куку; состав `scope`; склейка `authURL` через `?`/`&`; `ApplyProviderSettings` перетирает список провайдеров целиком (провайдер, заведённый владельцем в панели, исчезнет при подъёме); `down` не возвращает `OTP.Enabled`.
11. **Границы спеки** (5 пунктов): два входа одновременно в двух вкладках; поведение при отказе приведения настроек провайдера; остальная поверхность аутентификации хранилища (`confirm-password-reset`, `request-verification`, `confirm-verification`, `request-email-change` — перечень «что выключено» в спеке закрыт тремя пунктами, а поверхность шире); отзыв доступа внутри срока сессии; «владелец закрывает чужие сессии немедленно» существует только как ручная правка в панели.
12. **Имя куки состояния `transcriber_login` в спеке не нормировано**, в отличие от `transcriber_session`; **комментарий шага `up202608110001`** до сих пор утверждает «Защищённым поле не помечено намеренно» — после выкладки два шага противоречат друг другу в исходнике.
13. **Литерал `"users"` живёт в четырёх местах** при существующих константах имён коллекций (`FilesCollection`, `JobsCollection`).
**Выброшено как вкусовщина (3):** имена ключей `auth.*` как таковые (`CLAUDE.md` уже относит имя ключа конфига к необратимому — повторение записанного, а не находка); замечание про JSON-404 катч-олла на `/` (поведение хранилища, не этого кода, последствие не названо); предложение обобщить сборку адреса согласия сверх пункта S (работающий частный случай, последствия сверх S нет).
### Четыре строки, которых не принесёт ни один проход
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его не открывает. Расхождение изменения с записанным решением ловит скилл `av-dev-docs:healthcheck`, а не ревью. В этом изменении решений владельца названо минимум три (архив бессрочный, петля обмена внутри процесса, семь суток сессии) — ни одно против ADR не сверено.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` прогон не открывал. Всякое число в этом отчёте снято на этом прогоне и сопровождено командой или выводом; чисел из записанных наблюдений здесь нет.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.** Различение «идиоматично против распространено» не спрашивает никто с тех пор, как упразднён проход про идиоматичность; к новому коду (`auth.go`, `session.go`, `provider.go`) это относится целиком.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.** Проход независимой реализации снят по стоимости, а не по замеру. «Не знаю, чего не знаю» про форму решения входа — а форма здесь нащупывалась по ходу, это и подняло метку до `large` — не достаёт никто.
Метка `large`, поэтому пятая строка (про `small`) не применяется — дома всех трёх тем `security`, `operations`, `architecture` открывались.
@@ -0,0 +1,298 @@
## ADDED Requirements
### Requirement: Вход через внешнего провайдера
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
MUST не заводить: учётные записи держит провайдер, и это граница домена из
паспорта.
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
выдавал. Без этой сверки вход принимает чужой код.
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
держит обработчик возврата открытым до упора, а «провайдер медленный»
становится неотличим от «провайдер отказал».
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
`GET` его срабатывание уносится переходом по чужой ссылке.
#### Scenario: Человек входит впервые
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход и возвращается с кодом провайдера
- **THEN** учётная запись заводится, а сессия открывается
- **AND** дальнейший запрос к API от этой сессии проходит
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
средствами хранилища его требует.
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
#### Scenario: Признаки носителя состояния
- **WHEN** сервис уводит человека к провайдеру
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
что и кука сессии
#### Scenario: Возврат нельзя переиграть
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
- **THEN** сессия не открывается, а ответ несёт отказ
#### Scenario: Возврат с чужим состоянием
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
выдавал
- **THEN** сессия не открывается, а ответ несёт отказ
- **AND** учётная запись не заводится
#### Scenario: Провайдер отказал
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
- **THEN** сессия не открывается, а ответ несёт отказ
### Requirement: Иных способов открыть сессию нет
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
запись и получить сессию. Собственное создание записи в коллекции пользователей,
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
выключены настройкой коллекции.
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования закрытие приёма обходится
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
настоящему входу с этим адресом.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а сессия не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
### Requirement: Сессия предъявляется кукой
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
(`SameSite=Lax` или строже).
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
Сервис MUST перекладывать значение куки в этот заголовок **только когда
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи
и опросом готовности. Собственная поверхность хранилища под него не подпадает:
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и
расширение слоя на всё сняло бы эту защиту молча.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
- **WHEN** сервис ставит куку сессии
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
#### Scenario: Предъявленный заголовок побеждает куку
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
собранные логи, откуда её не убрать.
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
#### Scenario: Значения сессии нет в журнале
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой
- **THEN** значение сессии не встречается ни в одной журнальной записи
#### Scenario: Адреса почты нет в журнале
- **WHEN** человек проходит вход и учётная запись заводится
- **THEN** адрес его почты не встречается ни в одной журнальной записи
### Requirement: Сессия переживает перезапуск сервиса
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
#### Scenario: Прежняя кука годна после перезапуска
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** сервис поднимается заново на том же хранилище
- **THEN** запрос с прежней кукой проходит
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, числом в настройке
коллекции и тем же числом в сроке жизни куки. Умолчание хранилища MUST не
применяться: оно даёт пять суток, и это число никем не выбрано.
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
человек, которому Authelia закрыла доступ, работает до истечения своей сессии.
Паспорт опирается на отзыв в Authelia как на способ остановить того, кто
тратит слишком много, — значит срок сессии и есть цена этой остановки.
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
#### Scenario: Сессия не продлевает саму себя
- **GIVEN** человек вошёл и получил сессию
- **WHEN** этой же сессией он просит продлить её
- **THEN** ответ несёт отказ, а нового значения в нём нет
#### Scenario: Сессия истекает назначенным сроком
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** назначенный срок прошёл
- **THEN** запрос с этой кукой получает отказ
#### Scenario: Владелец закрывает чужую сессию
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** владелец обесценивает сессии этой учётной записи
- **THEN** запрос с прежней кукой получает отказ
### Requirement: Выход прекращает доступ
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
у браузера. Куку сервис при этом MUST убрать тоже.
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
продолжало бы открывать доступ до самого своего истечения.
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
человек уверен, что вышел.
#### Scenario: После выхода прежняя кука не работает
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
- **THEN** запрос получает отказ
#### Scenario: Выход убирает куку
- **WHEN** человек выходит
- **THEN** ответ убирает куку сессии у браузера
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
#### Scenario: Пропущенный провайдером получает доступ
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
- **THEN** учётная запись заводится, а доступ открывается
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
для заведения записи
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
наблюдение за сервисом.
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
адреса не несут.
#### Scenario: Проба здоровья доступна анонимно
- **WHEN** запрос приходит на `GET /health` без сессии
- **THEN** ответ имеет код `200`
#### Scenario: Метрики доступны анонимно
- **WHEN** запрос приходит на `GET /metrics` без сессии
- **THEN** ответ имеет код `200`
### Requirement: Секрет провайдера живёт в конфиге
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
а не заводиться однажды шагом схемы.
Причина второго требования в необратимости шага схемы: применённый шаг не
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
вовсе — вход сломался бы после ротации.
#### Scenario: Секрета нет в журнале
- **WHEN** сервис поднимается с настроенным провайдером
- **THEN** значение секрета не встречается ни в одной журнальной записи
#### Scenario: Смена секрета доезжает до хранилища
- **GIVEN** сервис уже поднимался с прежним секретом
- **WHEN** секрет в конфиге заменён и сервис поднят заново
- **THEN** настройки провайдера в хранилище несут новое значение
@@ -0,0 +1,104 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
видно было анонимно.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни задачи не заводится
- **AND** тело ответа не несёт данных задачи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
пока текста нет: пустая строка на месте отсутствующего текста читается как
«расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
#### Scenario: Задача найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние заведённой задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### Scenario: Сессии нет
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
состояние по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
@@ -0,0 +1,77 @@
## MODIFIED Requirements
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
здесь нет — его заводит отдельная задача.
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
недосмотр.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **AND** забирающий предъявил сессию и взял по ней токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Без сессии файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают без сессии
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
#### Scenario: Конвейер читает файл без сессии
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
@@ -0,0 +1,121 @@
## 1. Конфигурация
- [x] 1.1 Завести секцию конфига под провайдера: адрес авторизации, адрес обмена
кода, адрес сведений о пользователе, идентификатор клиента, секрет клиента,
адрес возврата
- [x] 1.2 Дописать те же ключи в `config.dist.toml` с пустыми значениями и
комментарием, откуда их брать
- [x] 1.3 Проверить, что незаполненный конфиг роняет старт с внятным
сообщением, а не поднимает сервис с молча выключенным входом
## 2. Провайдер в хранилище
- [x] 2.1 Завести шаг схемы, включающий провайдера `oidc` у коллекции
пользователей; файл шага именуется по правилу проекта и не переписывает
прежние
- [x] 2.2 Тем же шагом закрыть создание записи в коллекции пользователей и
выключить вход по паролю, одноразовый код и восстановление доступа: умолчание
библиотеки оставляет их открытыми
- [x] 2.3 Тем же шагом назначить срок жизни сессии числом вместо умолчания в
пять суток
- [x] 2.4 При подъёме сервиса приводить настройки провайдера к значениям
конфига: адреса, идентификатор клиента, секрет
- [x] 2.5 Убедиться, что секрет не попадает в журнал ни при подъёме, ни при
ошибке настройки
## 3. Вход, возврат, выход
- [x] 3.1 `GET /auth/login`: завести состояние и проверочный код PKCE, положить
во временную куку с теми же признаками, что у сессионной, увести на адрес
авторизации провайдера
- [x] 3.2 `GET /auth/callback`: сверить состояние с выданным, отвергнуть
несовпавшее и уже употреблённое, обменять код средствами хранилища с
таймаутом, поставить куку сессии, убрать временную
- [x] 3.3 Кука сессии зовётся `transcriber_session` и несёт `HttpOnly`,
`SameSite` и `Secure`; последний берётся из конфига с умолчанием «включено»
- [x] 3.4 `POST /auth/logout`: сперва обесценить ключ токенов учётной записи,
затем убрать куку сессии
- [x] 3.5 Промежуточный слой перекладывает значение куки в заголовок
`Authorization`, только когда заголовка нет, и только на адресах приложения
## 4. Закрытие API
- [x] 4.1 `POST /api/audio` и `GET /api/status/{id}` требуют узнанного
отправителя; отказ — код `401`
- [x] 4.2 Отказ по отсутствию сессии наступает раньше чтения тела запроса
- [x] 4.3 `GET /health` и `GET /metrics` остаются доступны без сессии
- [x] 4.4 Отказ без сессии одинаков для заведённой и неизвестной задачи
- [x] 4.5 Пометить поле файла защищённым тем же шагом схемы: ссылка на файл
перестаёт быть правом пройти по ней и требует сессии
- [x] 4.6 Убедиться, что конвейер по-прежнему читает файл из файловой системы, а
панель администратора его по-прежнему скачивает
## 5. Проверки
- [x] 5.1 Тест: оба эндпоинта API без куки отдают `401` и не заводят задачу;
`/health` и `/metrics` без куки отдают `200`
- [x] 5.2 Тест: запрос с прежней кукой проходит после пересоздания сервера
- [x] 5.3 Тест: после выхода запрос с прежней кукой получает отказ
- [x] 5.4 Тест: ни значение секрета, ни значение сессии, ни адрес почты не
встречаются в записанном выводе логгера
- [x] 5.5 Тест: возврат с невыданным состоянием не открывает сессию и не заводит
учётную запись; повторный возврат с уже употреблённым — тоже
- [x] 5.6 Тест: анонимное создание записи в коллекции пользователей и вход по
паролю получают отказ
- [x] 5.7 Тест: запрос с кукой и заголовком разом проходит по заголовку
- [x] 5.8 Тест: ссылка на файл записи без сессии отдаёт отказ, а с сессией —
тот же файл
- [x] 5.9 `task gate` зелёный целиком
## 6. Документация
- [x] 6.1 `docs/security.md`: первая строка периметра переписана под новый
периметр; названо новое место жизни секрета клиента — база; в разделе «Что
разграничивает доступ» записано, что допуск держит правило провайдера вне
репозитория, а сервис своей проверки не делает
- [x] 6.2 `docs/architecture.md`: capability `access` внесена в перечень
- [x] 6.3 `docs/conventions/config.md`: новые ключи конфига и расхождения
образца, если появились
## Критерии приёмки
Перенесены из записи задачи `oidc-login` дословно. Файл задачи закрытие удалит —
критерии обязаны его пережить.
- Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а
не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без
куки: код ответа 401 либо 302 на вход, тело без данных задачи. Тот же тест
проверяет вторую сторону границы: `GET /health` и `GET /metrics` без куки
отвечают 200.
- Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой
после пересоздания сервера проходит.
- Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос
получает отказ.
- Секрет провайдера не попадает ни в лог, ни в ответ. Оракул — тест на отсутствие
значения секрета в записанном выводе логгера.
- Первая строка `docs/security.md` описывает новый периметр. Оракул — `task
gate`, шаг `docs.py check`.
**Сужение против исходного критерия, объявленное ревью дизайна:** код отказа —
`401`, без допуска `302`. Оба адреса судят внешнюю программу, а не браузер, и
`302` для программы означает «получил 200 со страницей входа»; `curl -L` при нём
уходит постить тело на страницу входа провайдера. Дельта-спека `intake`
нормирует `401` двумя сценариями.
## Рубрика ревью дизайна
Порождена проходом `rubric` до чтения артефактов; сюда переносятся пункты,
ставшие приёмочными сверх критериев задачи.
- Отказ без сессии наступает раньше чтения тела и раньше обращения к хранилищу.
- Форма отказа одна и та же у существующего и несуществующего ресурса.
- Ни одно значение, дающее доступ, не печатается: код провайдера, секрет
клиента, значение сессии, адрес почты.
- Правило доступа читается как «всё требует сессии, кроме перечня», а перечень
открытого живёт в одном месте.
- Все прочие способы получить сессию к тому же субъекту выключены либо названы
поимённо с обоснованием, почему они не обход.
- Возврат от провайдера отвергается без состояния, с чужим, с истёкшим и с уже
употреблённым — до обмена кода.
- У обращения к провайдеру есть таймаут, и «медленный» отличается от «отказал».
- Исход входа и выхода не зависит от порядка параллельных операций.
+347
View File
@@ -0,0 +1,347 @@
# access Specification
## Purpose
Кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
открытыми.
Разграничения записей по владельцу здесь **нет**: всякий вошедший видит ровно
то же, что видел прежде аноним. Его заводит отдельная задача, и до неё сессия
отвечает только на вопрос «узнан ли пришедший», а не «чьё он смотрит».
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
белым списком, и с учётной записью приложения тот список не связан.
## Requirements
### Requirement: Вход через внешнего провайдера
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
MUST не заводить: учётные записи держит провайдер, и это граница домена из
паспорта.
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
выдавал. Без этой сверки вход принимает чужой код.
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
средствами хранилища его требует.
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
Уборка носителя MUST происходить до записи ответа. Отложенная не работает вовсе:
заголовки фиксируются в момент, когда ответ начинают писать, и позднейшая правка
до браузера не доезжает.
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
держит обработчик возврата открытым неограниченно долго, а «провайдер медленный»
становится неотличим от «провайдер отказал».
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
`GET` его срабатывание уносится переходом по чужой ссылке.
#### Scenario: Человек входит впервые
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход и возвращается с кодом провайдера
- **THEN** учётная запись заводится, а сессия открывается
- **AND** дальнейший запрос к API от этой сессии проходит
#### Scenario: Признаки носителя состояния
- **WHEN** сервис уводит человека к провайдеру
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
что и кука сессии
#### Scenario: Возврат нельзя переиграть
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
- **THEN** сессия не открывается, а ответ несёт отказ
#### Scenario: Возврат с чужим состоянием
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
выдавал
- **THEN** сессия не открывается, а ответ несёт отказ
- **AND** учётная запись не заводится
#### Scenario: Провайдер отказал
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
- **THEN** сессия не открывается, а ответ несёт отказ
### Requirement: Иных способов открыть сессию нет
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
запись и получить сессию. Собственное создание записи в коллекции пользователей,
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
выключены настройкой коллекции.
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования закрытие приёма обходится
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
настоящему входу с этим адресом.
Закрытие MUST не отменять заведения записи самим входом: запись при первом входе
заводит внутренний запрос обмена, и правило, отвергающее его наравне с
посторонним, оставляет сервис без единого способа войти.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Вход у провайдера запись заводит
- **GIVEN** учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход у провайдера
- **THEN** учётная запись появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а сессия не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
### Requirement: Сессия предъявляется кукой
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
(`SameSite=Lax` или строже).
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
Сервис MUST перекладывать значение куки в этот заголовок **только когда
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи
и опросом готовности. Собственная поверхность хранилища под него не подпадает:
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и
расширение слоя на всё сняло бы эту защиту молча.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
- **WHEN** сервис ставит куку сессии
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
#### Scenario: Предъявленный заголовок побеждает куку
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
собранные логи, откуда её не убрать.
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
Причина отказа, пришедшая от провайдера строкой запроса, MUST приводиться к
перечню известных: значение целиком задаёт тот, кто шлёт запрос, и без
приведения аноним пишет в журнал что угодно и сколько угодно.
#### Scenario: Значения сессии нет в журнале
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой
- **THEN** значение сессии не встречается ни в одной журнальной записи
#### Scenario: Адреса почты нет в журнале
- **WHEN** человек проходит вход и учётная запись заводится
- **THEN** адрес его почты не встречается ни в одной журнальной записи
### Requirement: Сессия переживает перезапуск сервиса
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
#### Scenario: Прежняя кука годна после перезапуска
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** сервис поднимается заново на том же хранилище
- **THEN** запрос с прежней кукой проходит
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, и тем же числом
задавать срок жизни куки. Умолчание хранилища MUST не применяться: оно даёт пять
суток, и это число никем не выбрано.
Назначаться срок MUST при каждом подъёме, а не шагом схемы: применённый шаг не
переписывается, и число, положенное туда, разошлось бы со сроком жизни куки при
первой же правке — браузер получил бы новый срок, а хранилище продолжило выдавать
прежний.
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
человек, которому провайдер закрыл доступ, работает до истечения своей сессии.
Паспорт опирается на отзыв у провайдера как на способ остановить того, кто
тратит слишком много, — значит срок сессии и есть цена этой остановки.
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
#### Scenario: Сессия не продлевает саму себя
- **GIVEN** человек вошёл и получил сессию
- **WHEN** этой же сессией он просит продлить её
- **THEN** ответ несёт отказ, а нового значения в нём нет
#### Scenario: Сессия истекает назначенным сроком
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** назначенный срок прошёл
- **THEN** запрос с этой кукой получает отказ
#### Scenario: Владелец закрывает чужую сессию
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** владелец обесценивает сессии этой учётной записи
- **THEN** запрос с прежней кукой получает отказ
### Requirement: Выход прекращает доступ
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
у браузера. Куку сервис при этом MUST убрать тоже.
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
продолжало бы открывать доступ до самого своего истечения.
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
человек уверен, что вышел.
#### Scenario: После выхода прежняя кука не работает
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
- **THEN** запрос получает отказ
#### Scenario: Выход убирает куку
- **WHEN** человек выходит
- **THEN** ответ убирает куку сессии у браузера
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
#### Scenario: Пропущенный провайдером получает доступ
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
- **THEN** учётная запись заводится, а доступ открывается
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
для заведения записи
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
наблюдение за сервисом.
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
адреса не несут.
#### Scenario: Проба здоровья доступна анонимно
- **WHEN** запрос приходит на `GET /health` без сессии
- **THEN** ответ имеет код `200`
#### Scenario: Метрики доступны анонимно
- **WHEN** запрос приходит на `GET /metrics` без сессии
- **THEN** ответ имеет код `200`
### Requirement: Секрет провайдера живёт в конфиге
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
а не заводиться однажды шагом схемы.
Причина второго требования в необратимости шага схемы: применённый шаг не
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
вовсе — вход сломался бы после ротации.
Незаполненная или негодная настройка входа MUST ронять старт с перечнем ключей и
без их значений. Форма адресов проверяется там же: непустая, но негодная строка
иначе отвергается хранилищем позже — из хука подъёма, до регистрации пробы
здоровья, — и сервис падает целиком, не оставив владельцу даже кода состояния.
#### Scenario: Секрета нет в журнале
- **WHEN** сервис поднимается с настроенным провайдером
- **THEN** значение секрета не встречается ни в одной журнальной записи
#### Scenario: Смена секрета доезжает до хранилища
- **GIVEN** сервис уже поднимался с прежним секретом
- **WHEN** секрет в конфиге заменён и сервис поднят заново
- **THEN** настройки провайдера в хранилище несут новое значение
#### Scenario: Негодная настройка роняет старт
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
- **THEN** старт кончается отказом, а отказ называет имена ключей
- **AND** значений этих ключей в отказе нет
+52 -8
View File
@@ -14,12 +14,19 @@ Telegram делит с ним общий шаг заведения задачи,
### Requirement: Приём записи по HTTP ### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`. ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым, Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча. и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных. пригодность содержимого узнаёт у источника метаданных.
@@ -27,16 +34,28 @@ MUST нести идентификатор задачи полем `job_id` и
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`. хранилище, и нормирует её capability `storage`.
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
видно было анонимно.
#### Scenario: Запись принята #### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` - **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status` - **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created` со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом - **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни задачи не заводится
- **AND** тело ответа не несёт данных задачи
#### Scenario: Поля с записью нет #### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio` - **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи - **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится - **AND** ни файла, ни задачи не заводится
@@ -44,6 +63,7 @@ MUST нести идентификатор задачи полем `job_id` и
#### Scenario: Размеру записи приём не судья #### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины - **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет - **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
@@ -192,23 +212,47 @@ MUST нести идентификатор задачи полем `job_id` и
### Requirement: Опрос готовности задачи ### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние `GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
полем `status` и время заведения полем `created_at`, а текст расшифровки полем получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет: текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
пустая строка на месте отсутствующего текста читается как «расшифровка пуста». `job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
пока текста нет: пустая строка на месте отсутствующего текста читается как
«расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
#### Scenario: Задача найдена #### Scenario: Задача найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние заведённой задачи - **WHEN** программа спрашивает состояние заведённой задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at` - **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### Scenario: Сессии нет
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
состояние по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Расшифровки ещё нет #### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста - **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе - **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет #### Scenario: Задачи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние по неизвестному идентификатору - **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче - **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
+34 -2
View File
@@ -91,7 +91,22 @@ MUST завести свою схему и принимать записи об
### Requirement: Файл отдаётся ссылкой ### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи. Отданный файл MUST совпадать с принятым по длине. записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
здесь нет — его заводит отдельная задача.
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
недосмотр.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом. Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
@@ -101,6 +116,10 @@ MUST завести свою схему и принимать записи об
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно. бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
@@ -112,9 +131,22 @@ MUST завести свою схему и принимать записи об
#### Scenario: Файл забирают по ссылке #### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище - **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают - **AND** забирающий предъявил сессию и взял по ней токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого - **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Без сессии файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают без сессии
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Конвейер читает файл без сессии
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
#### Scenario: Ссылка ведёт в никуда #### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет - **WHEN** запрашивают ссылку на запись, которой нет
+11 -7
View File
@@ -3,7 +3,7 @@
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь - **Категория:** Очередь
- **Зачем:** HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору. - **Зачем:** HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору.
- **Теги:** goal:multi-user, question - **Теги:** goal:multi-user
Двигает пункты 1 и 3 «Завершения» цели: неаутентифицированный запрос к записям Двигает пункты 1 и 3 «Завершения» цели: неаутентифицированный запрос к записям
не проходит ни к странице, ни к API; вход идёт через OIDC у Authelia, а выход из не проходит ни к странице, ни к API; вход идёт через OIDC у Authelia, а выход из
@@ -27,7 +27,7 @@
Authelia, идентификатор клиента, секрет, соответствие полей учётной записи; Authelia, идентификатор клиента, секрет, соответствие полей учётной записи;
- эндпоинты входа и выхода, которые PocketBase приносит своими; - эндпоинты входа и выхода, которые PocketBase приносит своими;
- `POST /api/audio` и `GET /api/status/:id` — оба уходят за аутентификацию; - `POST /api/audio` и `GET /api/status/:id` — оба уходят за аутентификацию;
- `GET /health` и `GET /metrics` решить и записать, остаются ли открытыми; - `GET /health` и `GET /metrics` — остаются открытыми и сессии не требуют;
- хранение сессии: её ведёт PocketBase, и решить надо, чем она предъявляется - хранение сессии: её ведёт PocketBase, и решить надо, чем она предъявляется
приложению; приложению;
- секция конфигурации под провайдера: адрес, идентификатор клиента, секрет; - секция конфигурации под провайдера: адрес, идентификатор клиента, секрет;
@@ -39,7 +39,9 @@
- Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а - Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а
не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без
куки: код ответа 401 либо 302 на вход, тело без данных задачи. куки: код ответа 401 либо 302 на вход, тело без данных задачи. Тот же тест
проверяет вторую сторону границы: `GET /health` и `GET /metrics` без куки
отвечают 200.
- Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой - Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой
после пересоздания сервера проходит. после пересоздания сервера проходит.
- Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос - Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос
@@ -57,7 +59,9 @@
тоже не трогаем: наружу её закрывает Authelia на обратном прокси, а это работа тоже не трогаем: наружу её закрывает Authelia на обратном прокси, а это работа
выкладки. выкладки.
## Вопросы `GET /health` и `GET /metrics` за аутентификацию не уходят — решено 2026-08-12.
Сессии нет ни у пробы здоровья, ни у сборщика Prometheus, и вход по личным
Остаются ли `GET /health` и `GET /metrics` открытыми? Прокси и система сбора токенам эта задача не заводит. Наружу их закрывает то же правило обратного
метрик сессии не имеют, но и наружу их отдавать незачем. прокси, что и панель администратора, — работа выкладки. Пока правило не
поставлено, `/metrics` отдаёт наружу объёмы работы сервиса: число задач, размеры
и длительности записей; содержимого расшифровок в них нет.