вход переехал на доверенный заголовок Authelia вместо собственного OIDC

- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
av
2026-08-22 20:24:22 +03:00
parent e4441f3c49
commit 7f33c957e5
63 changed files with 5257 additions and 2305 deletions
+106 -53
View File
@@ -3,14 +3,21 @@
## Периметр
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Без
входа открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton`
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
`trusted-header-login`, называет она его **заголовком, который ставит обратный
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
**каждый** запрос, и отзыв доступа действует со следующего.
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton`
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
принадлежащий ни одному корню сервиса. Иначе не вошедший не дошёл бы до входа
вовсе: закрытая сессией разметка отдала бы ему отказ вместо экрана. Данных
открытость не касается — всякий адрес под корнем приложения сессии по-прежнему
требует. Находки строятся против этого — сегодняшнего — периметра.
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
открытость не касается — всякий адрес под корнем приложения узнанного
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
@@ -58,13 +65,49 @@ Telegram — связи чата с учётной записью сервис
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
прокси живёт в `pet-project-server`, вне этого репозитория.
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
секрет в настройки коллекции пользователей, и чтение файла базы становилось
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
и аноним войдёт под любым именем. Половину этой беды сервис закрывает сам:
запрос с двумя значениями `Remote-User` не узнаёт никого. Вторую половину
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
репозитория `pet-project-server`, и **требование к нему такое — заголовки
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
человек.
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.**
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси,
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого
соединения.
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
именно поэтому.
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
после ухода прежнего владельца, и тогда новый человек при первом же обращении
попадает в **существующую** запись и получает весь её архив — самое
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
может: неизменяемого признака заголовок не приносит. Не допускать
переиспользования — работа провайдера, и это принятая цена, записанная в
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
нечем ни слить, ни убрать.
Отсюда главное следствие, из которого читается всё остальное: **`POST
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
@@ -79,10 +122,11 @@ Telegram — связи чата с учётной записью сервис
| Вход | Канал | Кто может слать |
| --- | --- | --- |
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой вошедший; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой вошедший; значение вне закрытого перечня даёт `400` |
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения |
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
@@ -92,7 +136,6 @@ Telegram — связи чата с учётной записью сервис
| Вход | Канал | Кто может слать | Чья задача |
| --- | --- | --- | --- |
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
@@ -179,39 +222,48 @@ Storage, оттуда его читает SpeechKit. Третий путь —
## Что разграничивает доступ
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
([database.md](database.md), «Настройки с числовым значением»).
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
доходит до сервиса, — не значил бы ничего.
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
та же проверка, но она названа здесь отдельно, потому что это второй способ
предъявить ту же сессию.
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
здесь «сессия → токен файла → ссылка».
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
отзыв доступа у Authelia действует со следующего обращения.
Предъявленный собственный токен хранилища побеждает заголовок: им работает
владелец панели, и подмена его учётной записью пользователя отобрала бы у него
панель. Протухший и негодный токен предъявленными не считаются.
**Область узнавания сужена** до корня приложения и адреса выдачи файлового
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный
переписал бы себе ключ учётной записи на чужое имя.
- **Учётная запись** — заводится первым обращением с новым логином и находится
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом
схемы `202608220001`.
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла
помечено защищённым, правило просмотра коллекции пускает только владельца
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это
единственное значение, переживающее запрос**, и на его срок отзыв доступа до
файловой ссылки не доходит.
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12.
- **Заведение учётной записи** — только входом у провайдера. Собственное
создание записи, вход по паролю, одноразовый код и восстановление доступа
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
без этого закрытия вход обходился бы двумя запросами.
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление
доступа и продление — ни один не даёт доступа и не меняет учётной записи:
хранилище заводит коллекцию пользователей открытой, и без этого закрытия
узнавание обходилось бы двумя запросами.
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
учётной записи на них не заводит. Наружу их закрывает правило обратного
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
эти адреса не несут.
- **Приложение** — его разметка и ресурсы открыты без сессии, и ограничителя
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
частоты на них нет: правило заведено под корень приложения, а раздача стоит
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
для всех и собраны до всякого запроса. По ответу нельзя узнать, вошёл ли
кто-то, — вошедшему и не вошедшему отдаётся одно и то же.
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
кто-то, — узнанному и неузнанному отдаётся одно и то же.
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
есть владелец. Знание идентификатора задачи правом её читать больше не является
@@ -222,9 +274,9 @@ Storage, оттуда его читает SpeechKit. Третий путь —
| Механизм | Что даёт | Чья задача |
| --- | --- | --- |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
@@ -252,8 +304,8 @@ Storage, оттуда его читает SpeechKit. Третий путь —
наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
приложение от чужого имени.
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
из базы вместе с собственным входом.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
@@ -335,13 +387,14 @@ Storage, оттуда его читает SpeechKit. Третий путь —
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница.
- **Машина разработчика и то, что он на ней поднимает.** С 2026-08-15 в
репозитории лежит `cmd/oidcstub` — подставной провайдер OIDC, который выдаёт
сессию всякому спросившему и не сверяет ни секрета клиента, ни проверочного
кода PKCE. Двух вещей это не отменяет, и обе проверяемы: в образ он не едет
(ступень собирает `./cmd/transcriber` поимённо), а слушает петлевой адрес.
Периметра выкладки заглушка поэтому не касается; кто поднял её у себя в чужой
сети, отвечает за это сам.
- **Машина разработчика и то, что он на ней поднимает.** В репозитории лежит
`cmd/devtools` — оснастка разработчика; её подкоманда `proxy` встаёт на место
контура: ставит заголовок `Remote-User` и переправляет запрос сервису, не
проверяя ничего. С 2026-08-15 по 2026-08-22 ту же роль играл `cmd/oidcstub`,
подставной провайдер OIDC. Двух вещей это не отменяет, и обе проверяемы: в
образ оснастка не едет (ступень собирает `./cmd/transcriber` поимённо), а
слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял
её у себя в чужой сети, отвечает за это сам.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне