diff --git a/CLAUDE.md b/CLAUDE.md index 5ff07d4..a8a150d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,10 +33,15 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- Что нарушать нельзя. -- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object - Storage не попадают в git, в лог, в ответ пользователю и в колонку - `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во - всех местах выкладки. **critical** +- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object + Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и + в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют + вручную во всех местах выкладки. **critical** + *Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции + пользователей хранилища — туда его кладёт приведение настроек при каждом + подъёме, потому что применённый шаг схемы не переписывается и не пережил бы + ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные + места запрета это не отменяет. - **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла пользователя и его сообщение в лог не пишутся — только длина и идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. diff --git a/config.dist.toml b/config.dist.toml index 3d7b513..32cf45d 100644 --- a/config.dist.toml +++ b/config.dist.toml @@ -33,6 +33,32 @@ object_storage_region = "ru-central1" # Endpoint Object Storage 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] # Токен Telegram бота (получить у @BotFather в Telegram) diff --git a/docs/adr/ADR-2026-08-12-access-delegated-to-provider.md b/docs/adr/ADR-2026-08-12-access-delegated-to-provider.md new file mode 100644 index 0000000..4030717 --- /dev/null +++ b/docs/adr/ADR-2026-08-12-access-delegated-to-provider.md @@ -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`. diff --git a/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md b/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md index 1985cf1..216834b 100644 --- a/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md +++ b/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md @@ -3,6 +3,7 @@ - **Дата:** 2026-08-12 - **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md), раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал» +- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) ## Решение diff --git a/docs/adr/ADR-2026-08-12-oidc-exchange-via-own-route.md b/docs/adr/ADR-2026-08-12-oidc-exchange-via-own-route.md new file mode 100644 index 0000000..786004d --- /dev/null +++ b/docs/adr/ADR-2026-08-12-oidc-exchange-via-own-route.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 соблюдено дословно, а + не «по духу». +- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки + под смену формата ответа доезжает само. +- `−` петля через собственный роутер: обработчик зовёт сервис, частью которого + сам является. Это новый для проекта вид узла, и его придётся объяснять на + каждом следующем изменении. +- `−` ответ разбирается текстом, типизированная ошибка теряется: причина отказа + обмена доступна только кодом состояния. +- `−` роутер хранилища пришлось собирать **один раз** и держать полем: его + сборка вешает обработчики на само приложение и без идентификатора, поэтому + повторная не заменяет прежние. Ревью кода нашло это построенным путём — + анонимный запрос копил обработчики без предела, а каждое сохранение задачи + конвейером проходило по всем накопленным. diff --git a/docs/adr/ADR-2026-08-12-protected-file-behind-session.md b/docs/adr/ADR-2026-08-12-protected-file-behind-session.md new file mode 100644 index 0000000..f9dfa32 --- /dev/null +++ b/docs/adr/ADR-2026-08-12-protected-file-behind-session.md @@ -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`; до неё круг сузился с «кто угодно из интернета» до «кто + угодно из вошедших», и это меньше, чем кажется. +- `−` отзыва у выданного токена нет, как не было у ссылки; смягчает только его + короткий срок. diff --git a/docs/adr/ADR-2026-08-12-session-without-refresh.md b/docs/adr/ADR-2026-08-12-session-without-refresh.md new file mode 100644 index 0000000..f1852e9 --- /dev/null +++ b/docs/adr/ADR-2026-08-12-session-without-refresh.md @@ -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 и обработка её +> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет +> способ остановить того, кто тратит слишком много. + +Число семь суток выбрано владельцем как компромисс: реже входить против дольше +ждать, пока отзыв доедет. + +Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с +причиной: применённый шаг не переписывается, поэтому число, положенное туда, +разошлось бы со сроком жизни куки при первой же правке — браузер получил бы +новый срок, а хранилище продолжило выдавать прежний. + +## Последствия + +- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за + семь суток; без этого он не доходил вовсе. +- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном + месте вместе со сроком куки. +- `−` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса + нет, так что вход начинается с перехода по адресу входа руками. +- `−` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно + закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи; + своего адреса у этого нет. +- `−` закрытие продления сделано слоем приложения, а не настройкой коллекции: + библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем. + Слой придётся помнить при всякой правке маршрутов. diff --git a/docs/adr/README.md b/docs/adr/README.md index c94cfac..b9481e2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | [Объявленную версию 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-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | | | 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index 78d36d4..7422537 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,8 +8,8 @@ [passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из этого ещё не решено — в разделе «Открытые вопросы». -Заведены четыре capability. Три первые нормируют **поведение сервиса** для его -потребителей; четвёртая — исключение из первого абзаца: она нормирует не сервис, а +Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его +потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает. - [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его @@ -23,6 +23,11 @@ - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` 2026-08-12; +- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли + его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что + её прекращает и какие адреса остаются открытыми. Задача `oidc-login` + 2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший + видит всё, что видел прежде аноним; - [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой его версии собирается сервис: одно число версии Go во всех местах, где она названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade` diff --git a/docs/conventions/config.md b/docs/conventions/config.md index d5b77c7..d7c9bb9 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -60,6 +60,11 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр *Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля `users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем. +*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами +вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не +говорит, какой формы значение здесь ждут. Пустым оставлен только +`client_secret` — он и есть секрет. + ## Поля по дискриминатору `type` Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из @@ -87,7 +92,7 @@ Ansible из `pet-project-server`). Приложение просто читае - Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`, `yandex.object_storage_access_key_id`, - `yandex.object_storage_secret_access_key`. + `yandex.object_storage_secret_access_key`, `auth.client_secret`. - Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, владелец — пользователь процесса (`1000:1000`). - В `config.dist.toml` секретные поля — пустые строки. @@ -115,6 +120,13 @@ TOML. Пустой токен бота ловится в `NewTelegramController` конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого места проверки нет. +Секция `[auth]` — первая, у которой проверка своя и стоит на старте: +`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет +процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с +молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом +было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение +`client_secret` в журнал попасть не должно. + ## Структура в коде - Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`. diff --git a/docs/database.md b/docs/database.md index 711ebbd..129bd81 100644 --- a/docs/database.md +++ b/docs/database.md @@ -96,9 +96,17 @@ capability, и третий смысл развёл бы одно слово п файлы, ни объекты в Object Storage не удаляются после завершения задачи: каталог и бакет растут неограниченно. - **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла - не помечено защищённым: право прочитать запись даёт знание её идентификатора, - и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в - хранилище **в журнал не пишется** — оно последняя часть ссылки. + помечено защищённым шагом `202608120001`, а правило просмотра коллекции + пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном + файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт + знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла + в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки. +- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её + сужает: создание записи разрешено только контексту обмена OIDC + (`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены. + Без этого сужения закрытие API обходится двумя запросами — завести себе + запись и войти паролем. Продление сессии закрыто слоем в приложении, а не + настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда. - **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции. `app.DB()` направляет всё, кроме выборок, в пул с единственным соединением, поэтому захваты выстраиваются в очередь. Порядок выборки — по времени @@ -134,6 +142,9 @@ capability, и третий смысл развёл бы одно слово п | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | +| Срок жизни сессии | 7 суток | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки в 5 суток никем не выбрано | +| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен | +| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым | **Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля diff --git a/docs/research/pocketbase.md b/docs/research/pocketbase.md index fa37a19..6de6d5a 100644 --- a/docs/research/pocketbase.md +++ b/docs/research/pocketbase.md @@ -99,6 +99,77 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало на `/_/` кодом `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`. Значит всё, что пришло параметром адреса, оседает там на пять +суток; проект умолчание не переопределяет. + ## Что отвергнуто и почему - **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто: diff --git a/docs/review.md b/docs/review.md index b3489ed..417cdf8 100644 --- a/docs/review.md +++ b/docs/review.md @@ -132,6 +132,20 @@ (CLAUDE.md, «Инварианты»). - `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`: реальный профиль нагрузки. Проект работает на единицах записей в день, и утверждения о росте остаются условиями, а не замерами; - `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 — правда, звали так, что он всегда отказывал, — а теперь получают длительность от подставного источника. Своего теста у `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 — образ не собирался, и этого не увидел никто [проскочил] **Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0` — diff --git a/docs/security.md b/docs/security.md index 5cf46c7..2c70c3e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 с расширением. Бакет один на все записи, префикса по пользователю нет. -- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не - помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а - отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется** +- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла + помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь + можно только с коротким токеном файла, который выдаётся по сессии, и запрос + без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет — + токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет + остаётся: **имя файла в хранилище в журнал не пишется** — иначе строка журнала вместе с идентификатором записи собирала бы ссылку целиком и работала бы бессрочно. В журнал идёт расширение своим полем. - **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное, @@ -124,18 +142,44 @@ Telegram отправителю. автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя с фамилией), а не с числовым идентификатором. Имя пользователя Telegram меняется владельцем в любой момент: список привязан к изменяемому значению. -- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу. -- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с - остальным. +- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется + кукой `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` | | Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | diff --git a/internal/adapter/repo/pocketbase/migrations.go b/internal/adapter/repo/pocketbase/migrations.go index a9d3272..3c1dcb9 100644 --- a/internal/adapter/repo/pocketbase/migrations.go +++ b/internal/adapter/repo/pocketbase/migrations.go @@ -1,6 +1,9 @@ package pocketbase import ( + "errors" + "fmt" + "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/migrations" @@ -15,6 +18,7 @@ import ( // `apis.Serve` прежде, чем поднять сервер. func init() { migrations.Register(up202608110001, down202608110001, "202608110001_init.go") + migrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go") } func up202608110001(app core.App) error { @@ -119,4 +123,130 @@ func down202608110001(app core.App) error { 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 } diff --git a/internal/adapter/repo/pocketbase/provider.go b/internal/adapter/repo/pocketbase/provider.go new file mode 100644 index 0000000..5172de2 --- /dev/null +++ b/internal/adapter/repo/pocketbase/provider.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index 34e8f1c..154c97e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,10 @@ package config import ( "fmt" + "net/url" "os" + "sort" + "strings" "github.com/BurntSushi/toml" ) @@ -12,6 +15,7 @@ type Config struct { Storage StorageConfig `toml:"storage"` Yandex YandexConfig `toml:"yandex"` Telegram TelegramConfig `toml:"telegram"` + Auth AuthConfig `toml:"auth"` } type ServerConfig struct { @@ -42,6 +46,69 @@ type TelegramConfig struct { 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 func defaultConfig() *Config { return &Config{ @@ -66,6 +133,9 @@ func defaultConfig() *Config { BotToken: "", UpdateTimeout: 10, }, + Auth: AuthConfig{ + SecureCookie: true, + }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..65eda3b --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/internal/controller/http/auth.go b/internal/controller/http/auth.go new file mode 100644 index 0000000..e13b817 --- /dev/null +++ b/internal/controller/http/auth.go @@ -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, + }) +} diff --git a/internal/controller/http/auth_test.go b/internal/controller/http/auth_test.go new file mode 100644 index 0000000..6229262 --- /dev/null +++ b/internal/controller/http/auth_test.go @@ -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) +} diff --git a/internal/controller/http/login_test.go b/internal/controller/http/login_test.go new file mode 100644 index 0000000..1128659 --- /dev/null +++ b/internal/controller/http/login_test.go @@ -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") +} diff --git a/internal/controller/http/session.go b/internal/controller/http/session.go new file mode 100644 index 0000000..56a3533 --- /dev/null +++ b/internal/controller/http/session.go @@ -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() + }, + } +} diff --git a/internal/controller/http/transcribe.go b/internal/controller/http/transcribe.go index aa29cd6..4d73e28 100644 --- a/internal/controller/http/transcribe.go +++ b/internal/controller/http/transcribe.go @@ -44,6 +44,14 @@ type GetTranscribeJobResponse struct { // сохранены — публичный контракт API объявлен необратимым. func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) { api := r.Group("/api") + + // Оба адреса уходят за аутентификацию. Слой предъявления стоит перед + // проверкой и действует только здесь: собственная поверхность хранилища под + // него не подпадает, часть её защищена ровно тем, что браузер заголовка сам + // не шлёт. + api.Bind(SessionFromCookie()) + api.Bind(apis.RequireAuth()) + // Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись // раньше обработчика, без строки в журнале приёма. Приём размеру не судья, // поэтому предел тела равен потолку самой записи. diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index d0f51bb..a058506 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -70,6 +70,50 @@ type testEnv struct { handler *TranscribeHandler app core.App 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 — перехваченный журнал одной проверки. Свой на случай: общий на @@ -147,7 +191,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { mux, err := r.BuildMux() 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 собирает запрос из имени и содержимого. Файла на диске @@ -241,7 +294,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) { req := createMultipartRequest(t, "sample.m4a", content) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) @@ -304,7 +357,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, tc.req(t)) + env.serve(w, tc.req(t)) require.Equal(t, http.StatusBadRequest, w.Code) @@ -327,7 +380,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) { req := createMultipartRequest(t, "empty.m4a", nil) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) @@ -374,7 +427,7 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) { req := createMultipartRequest(t, tc.fileName, []byte("запись")) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) @@ -400,7 +453,7 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) { req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись")) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) @@ -419,7 +472,7 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) { req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе")) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusInternalServerError, w.Code) @@ -459,7 +512,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) { req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись")) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) @@ -482,7 +535,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) { req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе")) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusInternalServerError, w.Code) @@ -507,7 +560,7 @@ func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) { req := createMultipartRequest(t, "sample.mp3", []byte("запись")) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) @@ -528,7 +581,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) { req := createMultipartRequest(t, "sample.mp3", content) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) @@ -593,7 +646,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) { req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись")) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) 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) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) 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) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) 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) w := httptest.NewRecorder() - env.mux.ServeHTTP(w, req) + env.serve(w, req) require.Equal(t, http.StatusNotFound, w.Code) diff --git a/main.go b/main.go index 7ce8616..dc5767d 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,13 @@ func main() { 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 файла if err := godotenv.Load(); err != nil { logger.Warn("Warning: .env file not found, using system environment variables") @@ -172,6 +179,12 @@ func main() { // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // и второму серверу на нём взяться неоткуда. 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 }) + // Настройки провайдера приводятся к конфигу при каждом подъёме: + // применённый шаг схемы не переписывается, и секрет, положенный + // однажды шагом, не пережил бы ротации. + 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) se.Router.GET("/health", func(e *core.RequestEvent) error { diff --git a/openspec/changes/archive/2026-08-12-oidc-login/.openspec.yaml b/openspec/changes/archive/2026-08-12-oidc-login/.openspec.yaml new file mode 100644 index 0000000..5081c98 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-oidc-login/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-12 diff --git a/openspec/changes/archive/2026-08-12-oidc-login/design.md b/openspec/changes/archive/2026-08-12-oidc-login/design.md new file mode 100644 index 0000000..2b593e8 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-oidc-login/design.md @@ -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. Значение + выбирается при заведении клиента и попадает в конфиг; здесь оно не + фиксируется. diff --git a/openspec/changes/archive/2026-08-12-oidc-login/proposal.md b/openspec/changes/archive/2026-08-12-oidc-login/proposal.md new file mode 100644 index 0000000..6023aa7 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-oidc-login/proposal.md @@ -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`: первая строка периметра перестаёт быть верной. +- Панель администратора не трогается: в неё провайдер не пускает, и закрывает её + обратный прокси. diff --git a/openspec/changes/archive/2026-08-12-oidc-login/review/report.md b/openspec/changes/archive/2026-08-12-oidc-login/review/report.md new file mode 100644 index 0000000..9e93921 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-oidc-login/review/report.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 = +ИСХОД: код=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 = +вошедший кукой → 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` открывались. diff --git a/openspec/changes/archive/2026-08-12-oidc-login/specs/access/spec.md b/openspec/changes/archive/2026-08-12-oidc-login/specs/access/spec.md new file mode 100644 index 0000000..86fe201 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-oidc-login/specs/access/spec.md @@ -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** настройки провайдера в хранилище несут новое значение diff --git a/openspec/changes/archive/2026-08-12-oidc-login/specs/intake/spec.md b/openspec/changes/archive/2026-08-12-oidc-login/specs/intake/spec.md new file mode 100644 index 0000000..ac4f22a --- /dev/null +++ b/openspec/changes/archive/2026-08-12-oidc-login/specs/intake/spec.md @@ -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` и сообщение о ненайденной задаче diff --git a/openspec/changes/archive/2026-08-12-oidc-login/specs/storage/spec.md b/openspec/changes/archive/2026-08-12-oidc-login/specs/storage/spec.md new file mode 100644 index 0000000..0401c76 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-oidc-login/specs/storage/spec.md @@ -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** файл читается из файловой системы хранилища и шаг проходит diff --git a/openspec/changes/archive/2026-08-12-oidc-login/tasks.md b/openspec/changes/archive/2026-08-12-oidc-login/tasks.md new file mode 100644 index 0000000..3773bfa --- /dev/null +++ b/openspec/changes/archive/2026-08-12-oidc-login/tasks.md @@ -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` до чтения артефактов; сюда переносятся пункты, +ставшие приёмочными сверх критериев задачи. + +- Отказ без сессии наступает раньше чтения тела и раньше обращения к хранилищу. +- Форма отказа одна и та же у существующего и несуществующего ресурса. +- Ни одно значение, дающее доступ, не печатается: код провайдера, секрет + клиента, значение сессии, адрес почты. +- Правило доступа читается как «всё требует сессии, кроме перечня», а перечень + открытого живёт в одном месте. +- Все прочие способы получить сессию к тому же субъекту выключены либо названы + поимённо с обоснованием, почему они не обход. +- Возврат от провайдера отвергается без состояния, с чужим, с истёкшим и с уже + употреблённым — до обмена кода. +- У обращения к провайдеру есть таймаут, и «медленный» отличается от «отказал». +- Исход входа и выхода не зависит от порядка параллельных операций. diff --git a/openspec/specs/access/spec.md b/openspec/specs/access/spec.md new file mode 100644 index 0000000..1ce8207 --- /dev/null +++ b/openspec/specs/access/spec.md @@ -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** значений этих ключей в отказе нет diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index b84a969..23c9060 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -14,12 +14,19 @@ Telegram делит с ним общий шаг заведения задачи, ### Requirement: Приём записи по HTTP Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с -телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена -и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ -MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`. +телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**. +Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл, +ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть +сохранена и получить заведённую под неё задачу расшифровки в состоянии +`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние +полем `status`. + +Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую +не заплатит узнанный отправитель, не должна попасть даже в память. Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым, -и переименование поля ломает внешнюю программу молча. +и переименование поля ломает внешнюю программу молча. Появление отказа без +сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу. Приём не судит о годности записи сам: расширение он берёт из имени файла, а пригодность содержимого узнаёт у источника метаданных. @@ -27,16 +34,28 @@ MUST нести идентификатор задачи полем `job_id` и Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает хранилище, и нормирует её 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** ни файла, ни задачи не заводится @@ -44,6 +63,7 @@ MUST нести идентификатор задачи полем `job_id` и #### Scenario: Размеру записи приём не судья - **GIVEN** источник метаданных читает запись и отдаёт её длительность +- **AND** отправитель предъявил сессию - **WHEN** программа шлёт запись нулевой длины - **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет @@ -192,23 +212,47 @@ MUST нести идентификатор задачи полем `job_id` и ### Requirement: Опрос готовности задачи Сервис SHALL отдавать состояние задачи расшифровки по запросу -`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние -полем `status` и время заведения полем `created_at`, а текст расшифровки полем -`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет: -пустая строка на месте отсутствующего текста читается как «расшифровка пуста». +`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` и сообщение о ненайденной задаче diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index 1010f22..56e0af5 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -91,7 +91,22 @@ MUST завести свою схему и принимать записи об ### Requirement: Файл отдаётся ссылкой Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой -записи. Отданный файл MUST совпадать с принятым по длине. +записи, **и только узнанному отправителю**. Поле файла MUST быть помечено +защищённым: без этого ссылка открывает запись любому, кто её знает, и знание +ссылки становится правом. Отданный файл MUST совпадать с принятым по длине. + +Одной пометки мало: защищённый файл судится **коротким токеном файла**, который +узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра +коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только +владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу +здесь нет — его заводит отдельная задача. + +Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном. +Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не +недосмотр. + +Конвейер расшифровки этим не затронут: он читает файл из файловой системы +хранилища, а не по ссылке. Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом. @@ -101,6 +116,10 @@ MUST завести свою схему и принимать записи об логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы бессрочно. +Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь +требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы +половину ключа. + Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и @@ -112,9 +131,22 @@ MUST завести свою схему и принимать записи об #### Scenario: Файл забирают по ссылке - **GIVEN** запись принята и её файл лежит в хранилище -- **WHEN** ссылку на файл запрашивают +- **AND** забирающий предъявил сессию и взял по ней токен файла +- **WHEN** ссылку на файл запрашивают с этим токеном - **THEN** приходит тот же файл, и его длина совпадает с длиной принятого +#### Scenario: Без сессии файл не отдаётся + +- **GIVEN** запись принята и её файл лежит в хранилище +- **WHEN** ссылку на файл запрашивают без сессии +- **THEN** приходит отказ, а содержимого записи в ответе нет + +#### Scenario: Конвейер читает файл без сессии + +- **GIVEN** запись принята и ждёт расшифровки +- **WHEN** шаг конвейера берётся за неё +- **THEN** файл читается из файловой системы хранилища и шаг проходит + #### Scenario: Ссылка ведёт в никуда - **WHEN** запрашивают ссылку на запись, которой нет diff --git a/tasks/items/oidc-login.md b/tasks/items/oidc-login.md index 9c04b79..e5d1401 100644 --- a/tasks/items/oidc-login.md +++ b/tasks/items/oidc-login.md @@ -3,7 +3,7 @@ - **Тип:** feature - **Категория:** Очередь - **Зачем:** HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору. -- **Теги:** goal:multi-user, question +- **Теги:** goal:multi-user Двигает пункты 1 и 3 «Завершения» цели: неаутентифицированный запрос к записям не проходит ни к странице, ни к API; вход идёт через OIDC у Authelia, а выход из @@ -27,7 +27,7 @@ Authelia, идентификатор клиента, секрет, соответствие полей учётной записи; - эндпоинты входа и выхода, которые PocketBase приносит своими; - `POST /api/audio` и `GET /api/status/:id` — оба уходят за аутентификацию; -- `GET /health` и `GET /metrics` — решить и записать, остаются ли открытыми; +- `GET /health` и `GET /metrics` — остаются открытыми и сессии не требуют; - хранение сессии: её ведёт PocketBase, и решить надо, чем она предъявляется приложению; - секция конфигурации под провайдера: адрес, идентификатор клиента, секрет; @@ -39,7 +39,9 @@ - Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без - куки: код ответа 401 либо 302 на вход, тело без данных задачи. + куки: код ответа 401 либо 302 на вход, тело без данных задачи. Тот же тест + проверяет вторую сторону границы: `GET /health` и `GET /metrics` без куки + отвечают 200. - Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой после пересоздания сервера проходит. - Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос @@ -57,7 +59,9 @@ тоже не трогаем: наружу её закрывает Authelia на обратном прокси, а это работа выкладки. -## Вопросы - -Остаются ли `GET /health` и `GET /metrics` открытыми? Прокси и система сбора -метрик сессии не имеют, но и наружу их отдавать незачем. +`GET /health` и `GET /metrics` за аутентификацию не уходят — решено 2026-08-12. +Сессии нет ни у пробы здоровья, ни у сборщика Prometheus, и вход по личным +токенам эта задача не заводит. Наружу их закрывает то же правило обратного +прокси, что и панель администратора, — работа выкладки. Пока правило не +поставлено, `/metrics` отдаёт наружу объёмы работы сервиса: число задач, размеры +и длительности записей; содержимого расшифровок в них нет.