HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# Кого пускать в сервис, решает правило провайдера, а не сервис
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Кого пускать, решает провайдер, а не сервис»
|
||||
|
||||
## Решение
|
||||
|
||||
Сервис пускает всякого, кого пропустил провайдер, и **своей проверки допуска не
|
||||
делает**. Кто допущен, определяет правило Authelia на этого клиента — настройка
|
||||
выкладки, лежащая вне репозитория.
|
||||
|
||||
## Почему
|
||||
|
||||
Authelia — общий провайдер контура, а не выделенный под этот сервис: учётная
|
||||
запись в ней есть у всякого, кому её завели ради любого другого сервиса на том же
|
||||
сервере. Ревью дизайна назвало следствие прямо: механизм, приглашающий «второго
|
||||
человека», приглашает всех, кто уже есть у провайдера.
|
||||
|
||||
Очевидный ответ — проверять принадлежность к названной в конфиге группе своим
|
||||
кодом. Владелец от него отказался: это завело бы **второе место**, где решается
|
||||
допуск, и решать его пришлось бы в двух местах согласованно.
|
||||
|
||||
Цена отказа названа в источнике и повторена в модели угроз:
|
||||
|
||||
> Правило живёт вне репозитория, в настройках выкладки, и сервис на него
|
||||
> полагается так же, как полагается на обратный прокси в части панели
|
||||
> администратора. Настроенный слишком широко клиент открывает сервис всем, у кого
|
||||
> есть учётная запись в общей Authelia, — и проверить это по коду нельзя.
|
||||
|
||||
Запись заводится как **намеренный отказ от очевидного подхода**: проверку группы
|
||||
предложат снова, и без записанной причины она выглядит бесплатной.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` допуск решается в одном месте, а не в двух; изменение круга допущенных не
|
||||
требует ни правки кода, ни выкладки.
|
||||
- `+` сервис не читает из ответа провайдера ничего сверх нужного для заведения
|
||||
записи — ни групп, ни ролей.
|
||||
- `−` защита сервиса стала свойством настройки, лежащей в другом репозитории, и
|
||||
ревью её проверить не может: ни один проход не увидит, что клиент настроен
|
||||
слишком широко.
|
||||
- `−` ошибка в настройке клиента не имеет наблюдаемого признака внутри сервиса:
|
||||
посторонний, которого пропустила Authelia, выглядит как законный пользователь.
|
||||
- `−` разграничения по владельцу нет, поэтому цена ошибки в настройке — все
|
||||
записи и все расшифровки разом, а не одна учётная запись. Сузит это
|
||||
`record-ownership`.
|
||||
@@ -3,6 +3,7 @@
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
||||
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
|
||||
- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
|
||||
|
||||
## Решение
|
||||
|
||||
Обработчик возврата от провайдера зовёт **собственный адрес хранилища**
|
||||
`auth-with-oauth2` внутри процесса, через его же роутер, а не по сети и не
|
||||
разбирая ответ провайдера своими руками.
|
||||
|
||||
## Почему
|
||||
|
||||
Решение [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
|
||||
отдало разбор ответа провайдера хранилищу: только тогда учётные записи заводятся
|
||||
сами и видны в панели. Это решение не пересматривается — пересматривается способ
|
||||
до него дотянуться.
|
||||
|
||||
Проверка исходников библиотеки версии 0.39.10 показала, что обмен наружу не
|
||||
экспортирован: он живёт неэкспортированной функцией за собственным маршрутом.
|
||||
Остались три формы, и владелец выбрал первую:
|
||||
|
||||
> (а) внутрипроцессный вызов собственного маршрута `auth-with-oauth2`: решение
|
||||
> 2026-08-11 соблюдено дословно, цена — петля «наш обработчик → наш роутер → наш
|
||||
> обработчик», разбор JSON-ответа и потеря типизированной ошибки; (б) сборка
|
||||
> обмена из экспортированных кусков с сохранением записи и связи через `app.Save`:
|
||||
> прямой код без петли, цена — пересмотр решения 2026-08-11 отдельным ADR; (в)
|
||||
> отложить вход до появления фронтенда.
|
||||
|
||||
Запись заводится как **намеренный отказ от очевидного подхода**: собрать обмен
|
||||
своими руками выглядит проще и дешевле, и предложение вернётся, если причина не
|
||||
записана.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` разбор ответа провайдера, заведение учётной записи и связь её с внешним
|
||||
провайдером остаются за хранилищем — решение 2026-08-11 соблюдено дословно, а
|
||||
не «по духу».
|
||||
- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки
|
||||
под смену формата ответа доезжает само.
|
||||
- `−` петля через собственный роутер: обработчик зовёт сервис, частью которого
|
||||
сам является. Это новый для проекта вид узла, и его придётся объяснять на
|
||||
каждом следующем изменении.
|
||||
- `−` ответ разбирается текстом, типизированная ошибка теряется: причина отказа
|
||||
обмена доступна только кодом состояния.
|
||||
- `−` роутер хранилища пришлось собирать **один раз** и держать полем: его
|
||||
сборка вешает обработчики на само приложение и без идентификатора, поэтому
|
||||
повторная не заменяет прежние. Ревью кода нашло это построенным путём —
|
||||
анонимный запрос копил обработчики без предела, а каждое сохранение задачи
|
||||
конвейером проходило по всем накопленным.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
пункт 3
|
||||
|
||||
## Решение
|
||||
|
||||
Поле файла в хранилище **помечается защищённым**, а правило просмотра коллекции
|
||||
файлов пускает всякого узнанного. Ссылка `/api/files/<коллекция>/<запись>/<имя>`
|
||||
перестаёт быть правом пройти по ней: нужен короткий токен файла, который берут,
|
||||
предъявив сессию.
|
||||
|
||||
Запись заменяет [ADR-2026-08-12-file-link-open-but-not-logged](ADR-2026-08-12-file-link-open-but-not-logged.md).
|
||||
|
||||
## Почему
|
||||
|
||||
Прежнее решение было обусловленным и само назвало условие своего пересмотра:
|
||||
|
||||
> Решение действует до разграничения доступа: задачи `oidc-login` и
|
||||
> `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
|
||||
> записью.
|
||||
|
||||
Условие наступило. Прежний довод — «право прочитать задачу даёт знание её
|
||||
идентификатора, и файл встаёт вровень с `GET /api/status/:id`» — держался на том,
|
||||
что опрос готовности открыт анонимно. Этот change закрывает опрос за вход, и
|
||||
файл, оставшийся открытым, стал бы единственным анонимным путём к содержимому
|
||||
записи — самому чувствительному, что есть у проекта.
|
||||
|
||||
Вторая половина прежнего решения остаётся в силе: имя файла в журнал по-прежнему
|
||||
не пишется. Защищённое поле сужает право пройти, но не отменяет запрета —
|
||||
строка журнала со ссылкой собирала бы половину ключа.
|
||||
|
||||
Пометки самой по себе оказалось мало, и это выяснило ревью кода прогоном:
|
||||
защищённый файл судится **и** токеном, **и** правилом просмотра коллекции, а
|
||||
незаданное правило означает «только владелец панели». Файл не получал ни аноним,
|
||||
ни вошедший — сценарий спеки не исполнялся вовсе. Правило назначено тем же шагом
|
||||
схемы.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` содержимое записи перестало быть доступным по одному знанию ссылки; после
|
||||
закрытия API это был последний анонимный путь к нему.
|
||||
- `+` условие, названное прежней записью, отработало как задумано: решение
|
||||
пересмотрено записью, а не молча.
|
||||
- `−` ссылка усложнилась для потребителя: браузер с одной кукой файла не
|
||||
получает, нужен порядок «сессия → токен файла → ссылка». Будущее приложение
|
||||
обязано этот шаг делать, и задача про прослушивание записи начинается с него.
|
||||
- `−` разграничения по владельцу нет: токен файла берёт всякий вошедший, и по
|
||||
ссылке он получит **любую** запись, а не только свою. Сужение приносит
|
||||
`record-ownership`; до неё круг сузился с «кто угодно из интернета» до «кто
|
||||
угодно из вошедших», и это меньше, чем кажется.
|
||||
- `−` отзыва у выданного токена нет, как не было у ссылки; смягчает только его
|
||||
короткий срок.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Сессия живёт семь суток и не продлевает саму себя
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
пункт 6
|
||||
|
||||
## Решение
|
||||
|
||||
Срок жизни сессии — **семь суток**, назначается при каждом подъёме сервиса.
|
||||
Продление сессии **выключено**: адрес, которым хранилище меняет предъявленное
|
||||
значение на новое, закрыт слоем приложения.
|
||||
|
||||
## Почему
|
||||
|
||||
Умолчание хранилища — пять суток и продлеваемая сессия. Второе делает первое
|
||||
бессмысленным, и это выяснило ревью кода замером: предъявитель одного живого
|
||||
значения продлевает себе доступ бессрочно, никуда не входя.
|
||||
|
||||
Значение имеет то, на чём держится вся остановка перерасхода. Паспорт опирается
|
||||
на **отзыв доступа в Authelia** как на способ остановить того, кто тратит слишком
|
||||
много. Но сервис после входа к провайдеру не обращается: подпись сессии считается
|
||||
от значений в базе, и отзыв у провайдера до сервиса доходит **только** истечением
|
||||
срока. При живом продлении не доходит никогда — человек, которому закрыли доступ,
|
||||
сохраняет его навсегда.
|
||||
|
||||
Отвергнуто и названо ценой:
|
||||
|
||||
> сверяться с провайдером по расписанию — новая связь с Authelia и обработка её
|
||||
> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет
|
||||
> способ остановить того, кто тратит слишком много.
|
||||
|
||||
Число семь суток выбрано владельцем как компромисс: реже входить против дольше
|
||||
ждать, пока отзыв доедет.
|
||||
|
||||
Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с
|
||||
причиной: применённый шаг не переписывается, поэтому число, положенное туда,
|
||||
разошлось бы со сроком жизни куки при первой же правке — браузер получил бы
|
||||
новый срок, а хранилище продолжило выдавать прежний.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за
|
||||
семь суток; без этого он не доходил вовсе.
|
||||
- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном
|
||||
месте вместе со сроком куки.
|
||||
- `−` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса
|
||||
нет, так что вход начинается с перехода по адресу входа руками.
|
||||
- `−` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно
|
||||
закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи;
|
||||
своего адреса у этого нет.
|
||||
- `−` закрытие продления сделано слоем приложения, а не настройкой коллекции:
|
||||
библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем.
|
||||
Слой придётся помнить при всякой правке маршрутов.
|
||||
+5
-1
@@ -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) | |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+14
-3
@@ -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 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||
|
||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||
|
||||
@@ -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`. Значит всё, что пришло параметром адреса, оседает там на пять
|
||||
суток; проект умолчание не переопределяет.
|
||||
|
||||
## Что отвергнуто и почему
|
||||
|
||||
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
|
||||
|
||||
+105
-2
@@ -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` —
|
||||
|
||||
+64
-20
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user