вход переехал на доверенный заголовок 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
@@ -0,0 +1,361 @@
## Context
Сегодня сервис проводит вход сам: адрес `/auth/login` уводит человека к
Authelia, положив состояние и проверочный код PKCE в куку `transcriber_login`;
`/auth/callback` сверяет состояние и меняет принесённый код на сессию
внутрипроцессным запросом к собственному адресу хранилища; сессия уезжает кукой
`transcriber_session` и живёт семь суток. Секрет клиента ради этого обмена лежит
в конфиге и приводится к настройкам коллекции пользователей при каждом подъёме.
Обратный прокси контура (`files/caddyproxy/Caddyfile.template` в
`pet-project-server`) уже спрашивает Authelia через `forward_auth` и копирует
ответ в заголовки `Remote-User`, `Remote-Groups`, `Remote-Email`, `Remote-Name`
трём соседним сервисам. Правила для этого сервиса там нет: он не выложен.
Контейнер портов наружу не публикует.
Проект на стройке: данных на сервере нет, совместимость не требуется. Применённый
шаг схемы по-прежнему не переписывается.
**Что измерено пробником** на временной базе (прогон 2026-08-22, программа
удалена):
- коллекция `users` требует непустых `email` и `password`; поле `email`
переводится в необязательное шагом схемы, `password` — нет ни при каком
значении признака;
- уникальность почты держится **частичным** индексом (`WHERE email != ''`),
поэтому запись без почты законна и второй такой же не мешает;
- своя колонка с уникальным индексом отвергает вторую запись с тем же значением
и находится поиском по значению;
- запись, заведённая так, выдаёт файловый токен — то есть годится как учётная
запись предъявителя во всём, что делает сервис.
## Goals / Non-Goals
**Goals:**
- Узнавать пришедшего заголовком доверенного источника и никак иначе.
- Убрать собственный протокол входа целиком, вместе с куками, состоянием, PKCE,
обменом кода и выходом.
- Убрать секрет клиента из конфига и из базы.
- Оставить рабочим путь к файлу записи: предъявиться, взять у хранилища короткий
файловый токен, пройти по ссылке.
- Оставить рабочим вход владельца в панель его собственным паролем.
- Дать способ представиться на машине без прокси.
**Non-Goals:**
- Второй уровень доступа по `Remote-Groups`: его потребителя — страницы расхода
— ещё нет.
- Личные токены для программ: их заводит `api-tokens`.
- Правило обратного прокси и правило Authelia для домена сервиса: они живут в
`pet-project-server`, и эта работа их не пишет и не проверяет.
- Приведение пути к канонической форме (обход панели подменой знака): барьер
переезжает на домен, и правило на литерал пути перестаёт быть единственным, но
сама канонизация — не предмет этой работы.
## Decisions
### Р1. Доверие определяется адресом пира соединения
Заголовку верят, когда запрос пришёл с адреса из объявленного перечня. Адрес
берётся у самого соединения (`RemoteAddr`), а не из пересылаемых заголовков.
Отвергнуто:
- **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
запрос. Барьер, который подделывается той же строкой, что и обходится, не
барьер вовсе.
- **Готовое разрешение адреса у библиотеки** (`RealIP`). Оно читает
пересылаемый заголовок, когда настроен список доверенных прокси, а сам список
живёт в настройках хранилища — то есть в базе, а не в конфиге. Условие доверия
оказалось бы в месте, которое правят руками в панели.
- **«Контейнер портов не публикует, значит доверяем всякому».** Условие верное,
но непроверяемое отсюда: правка раскладки контейнера открыла бы сервис молча,
и оракула у второго критерия приёмки не было бы вовсе.
### Р2. Слой действует на объявленной области, а не на всём роутере
Слой вешается корневым — иначе к нужным адресам его не привязать, — но узнаёт
пришедшего **только на объявленной области**: корень приложения плюс адрес,
которым хранилище выдаёт короткий файловый токен. Область берётся из перечня
адресного пространства, а не пишется вторым списком; адрес файлового токена
добавляется к нему поимённо. Устроено это ровно так же, как сегодня устроен
запрет продления сессии: корневая привязка и проверка пути внутри.
Учётная запись ставится **только когда её ещё нет** — то есть после того, как
библиотека прочитала предъявленный токен и никого не нашла. Предъявленным
считается токен, который библиотека **прочитала успешно**: протухший и негодный
предъявленными не считаются, и такой запрос узнаётся заголовком.
**Первая редакция вешала узнавание на весь роутер, и это была дыра.** Прежняя
спека держала обратное требование — «область действия слоя MUST быть ограничена
адресами приложения… часть поверхности хранилища защищена сегодня ровно тем, что
браузер заголовка сам не шлёт, и расширение слоя на всё сняло бы эту защиту
молча», — а первая редакция снимала это требование вместе с кукой и замены ему не
давала. Сложение с Р5 (ключ учётной записи переезжает в обычную колонку
пользователей) давало путь захвата: узнанный человек шлёт
`PATCH /api/collections/users/records/{свой id}` и ставит себе ключом чужое имя,
а первое обращение настоящего владельца этого имени попадает в **его** учётную
запись — вместе со всем архивом. Правило правки у коллекции пользователей
библиотечное и разрешает править свою запись; **проверено пробником**: у
коллекции `users` `ListRule`, `ViewRule`, `UpdateRule` и `DeleteRule` равны
`id = @request.auth.id`, а применённый шаг `202608120001_oidc_login` трогает
только правило создания.
Второе следствие сужения: узнавание больше не срабатывает на пробе здоровья, на
метриках и на каждом ресурсе сборщика. Иначе всякий запрос за картинкой стоил бы
обращения к базе, а первый такой запрос с новым именем — записи в неё.
Отвергнуто: **вешать под корнем приложения и завести отдельный путь к файлу**.
Второй способ добраться до файла — второй дом одного правила, и разошлись бы они
на первой же правке разграничения.
### Р2а. Поверхность коллекции пользователей закрывается схемой
Новый шаг схемы снимает у коллекции пользователей все правила доступа —
перечисление, чтение, создание, правку и удаление, — оставляя их пустыми, то есть
«только владелец панели». Наш код читает и заводит запись мимо правил, панель
работает суперпользователем, своих экранов профиля сервис не заводит.
Это второй барьер поверх Р2, и он нужен именно вторым. Прежняя спека сама
называла защиту, державшуюся на том, что браузер не шлёт заголовка,
**случайной** — и была права; область слоя это та же случайность, только с
другой стороны. Сверку берёт на себя проверка схемы: коллекция пользователей
дописывается к шести, которые она уже сторожит, — сегодня она единственная из
семи проверяется только умолчаниями библиотеки.
Отсюда же норма, которой в первой редакции не было вовсе: **ключ учётной записи
не правится ничем, кроме заведения самим сервисом.** Правило доступа закрывает
путь снаружи, норма закрывает и правку рукой в панели: переписанный ключ отдаёт
архив следующему, кто придёт с этим именем, и вернуть его будет нечем.
### Р3. Сессия не выдаётся никакая
Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
заголовку, который прокси поставил, сходив к Authelia.
Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
значение, живущее семь суток, отозванный у провайдера человек работал до
истечения этого значения, и другого канала отзыва не было.
Отвергнуто: **выдавать сессию хранилища по первому запросу с заголовком** — куку
ставить, дальше верить ей. Дешевле в работе (слой срабатывал бы раз в неделю, а
не на каждом запросе), но возвращает ровно то, что задача убирает: значение,
переживающее отзыв. Семь суток вернулись бы вместе с ним.
Цена принятого решения: поиск учётной записи по значению заголовка идёт на
**каждом** запросе к сервису. Уникальный индекс делает это одним обращением к
базе; сервисом пользуются единицы человек, и замера здесь не требуется.
### Р4. Имена заголовков нормативны, настраивается перечень доверенных адресов
Имена `Remote-User`, `Remote-Name`, `Remote-Email` записываются в спеку и
живут константами — как жило имя куки сессии, и по той же причине: смена имени
молча перестаёт узнавать всех, а тест, который сам ставит и сам читает своё имя,
этого не замечает. Контур уже пишет эти имена трём соседним сервисам.
Настройкой приходит одно — перечень адресов, чьему заголовку верят. Он
непустой обязателен: пустой перечень значит «не верить никому», то есть сервис,
поднявшийся никого не узнающим, и молчать об этом старт не вправе.
Отвергнуто: **имена заголовков ключами конфига**. Второе место, где их можно
написать неверно, а выгоды нет: контур один и пишет одно.
Имена ключей конфига — необратимое. **Решено человеком на чекпоинте
2026-08-22:** секция `[auth]` остаётся под своим именем, в ней один ключ —
`trusted_proxies`. Довод: секция сохраняет смысл «кому мы верим на входе», а
проверка настроек на старте остаётся там же, где была.
### Р5. Ключ учётной записи — своя колонка
Значение `Remote-User` ложится в свою колонку коллекции пользователей,
уникальную. По ней запись ищется и по ней же заводится, если её ещё нет.
Отвергнуто:
- **Адрес почты ключом.** Authelia не обязана его отдавать, человек его меняет,
а первый вход с чужим адресом достался бы чужой записи — ровно тот захват,
ради которого прежний шаг схемы закрывал создание записи.
- **Таблица внешних учётных записей библиотеки.** Она принадлежит механике
OAuth2, которую эта работа убирает целиком.
- **Не брать `Remote-Email` вовсе.** Довод за: поля с личными сведениями,
у которого нет ни одного потребителя, в периметре быть не должно — ключом
почта не служит, в журнал и в ответ ей ходу нет, а ради неё шаг схемы ещё и
делает колонку необязательной. Отвергнуто потому, что потребитель назван и
ждёт своей очереди: задача `email-notification` шлёт готовый текст на адрес из
учётной записи, и строка про это стоит в модели угроз, раздел «Куда уходит
содержимое записи». Взять адрес заголовком в тот день будет нечем — заголовок
приходит с запросом человека, а рассылка идёт из конвейера.
### Р5а. Цена ключа названа в обе стороны
Переименование у провайдера заводит новую учётную запись — это первая половина, и
она была названа сразу. Вторая: **логин переиспользуем**. Человек, которому
выдали логин ушедшего, при первом же обращении попадает в **существующую** запись
и получает весь её архив — голосовые записи семьи, расшифровки, тексты, то есть
самое чувствительное, что у сервиса есть.
Записывается это в спеку и повторяется в модели угроз, а не чинится: неизменяемого
признака заголовок не приносит, и не допускать переиспользования логинов — работа
провайдера, а не сервиса. Дефект был бы в умолчании — в том, что риск не назван, и
следующий читатель счёл бы вопрос закрытым абзацем про переименование.
Третья сторона той же цены: у осиротевшей записи прежнего человека остаётся архив,
который нечем ни слить с новой, ни убрать — владелец записи назначается один раз и
не меняется, а учётная запись с записями не удаляется по норме `storage`.
Имя колонки — `provider_login`, и оно говорит о происхождении значения: это логин
человека **у провайдера**, а не наш идентификатор. Имя уезжает шагом схемы и
потому не переписывается.
Заведение записи: имя — из `Remote-Name`, почта — из `Remote-Email`, если тот
пришёл. Почта перестаёт быть обязательной (шаг схемы), пароль записи
обязателен всегда — ей ставится случайный, употребить его нельзя, потому что
вход по паролю у коллекции выключен.
Найденную запись повторное обращение **не переписывает**: имя и почта берутся
только при заведении. Иначе каждый запрос был бы записью в базу, а правка имени
в Authelia переписывала бы карточку человека молча, посреди его работы.
**Значение принимается, а не берётся как есть.** Пустое и состоящее из пробельных
знаков не узнаёт никого и записи не заводит: прокси штатно шлёт пустой заголовок
там, где никого не назвал, и по букве «первое обращение с новым значением» все
неназванные собрались бы в одну общую учётную запись с общим архивом. По той же
причине отвергается запрос, несущий **более одного** значения `Remote-User`:
прокси, настроенный добавлять заголовок вместо замены, оставляет присланный
анонимом рядом со своим, и умолчание «берём первый» отдаёт вход анониму. Сверх
того — предел длины и отказ на управляющие знаки, а в хранилище значение уходит
параметром, а не подстановкой в текст фильтра. Класс у проекта уже был: хвост
имени отправителя, уехавший меткой метрики, чинился приведением на входе, а не
запретом на выходе.
Сравнение при поиске — точное, знак в знак. Приведение регистра завело бы правило,
которого нет у провайдера: считает ли Authelia `admin` и `Admin` одним человеком,
сервису неизвестно, а угаданное правило склеило бы двух разных людей молча.
**Два отказа уникальности различаются.** По ключевой колонке — гонка двух первых
обращений одним именем: код повторяет поиск и продолжает. По любой другой —
почта, пришедшая от провайдера, уже занята другой учётной записью (общий
почтовый ящик, семья, группа) — запись заводится **без почты**, потому что почта
необязательна, и остаётся строка журнала с идентификатором записи. Без этого
разреза второй человек с той же почтой не завёлся бы никогда: повторный поиск по
имени снова ничего не нашёл бы, и исход выродился бы либо в цикл, либо в вечный
отказ без внятной причины.
**Отказ хранилища при узнавании — отказ сервиса, а не «вас не узнали».** Молчаливый
проход неузнанным показал бы человеку отказ входа там, где легла база.
### Р6. Заголовок с недоверенного адреса просто не действует
Слой при этом отказа не выдаёт. Отказ приходит там, где приходил и раньше, —
требованием учётной записи под корнем приложения, кодом `401`.
Отвергнуто: **отвечать отказом прямо в слое**. Проба здоровья, метрики и
разметка приложения открыты анонимно по спеке; отказ в слое закрыл бы их
всякому, кто пришлёт заголовок, — то есть чужая строка в запросе гасила бы
наблюдение за сервисом.
### Р7. Значение заголовка в журнал не идёт
Пишется факт и исход — «заголовок пришёл с недоверенного адреса», «учётная
запись заведена», — с идентификатором записи, но без значения. Довод тот же,
каким причина отказа провайдера приводилась к перечню известных: значением
целиком распоряжается тот, кто шлёт запрос, а с недоверенного адреса это ещё и
аноним. Плюс само имя принадлежит человеку — наравне с адресом почты, которому
спека уже запрещает попадать в журнал.
### Р7а. У правила «найти или завести человека» есть дом, и он не в транспорте
Правило — поиск по ключу, заведение при отсутствии, приём значения, разрез двух
отказов уникальности, «найденную не переписывать» — живёт **методом пакета
хранилища**, а слой транспорта только читает заголовок, судит адрес пира и зовёт
метод. Дом объявляется строкой в перечне единых точек проекта.
Причина в следующей задаче, а не в чистоте. `api-tokens` заводит **второй** способ
представиться и в своей записи прямо говорит: «два способа представиться и один
владелец». Правило, уложенное куском в слой транспорта, придётся тогда либо
продублировать вторым куском — и он разойдётся с первым на первой же правке, —
либо вытаскивать задним числом. Стоимость следующего изменения здесь считается:
при вынесенном правиле новый способ представиться стоит одного нового слоя.
Отвергнуто: **контракт в `internal/contract` с реализацией в адаптере**. Дороже на
интерфейс, у которого сегодня одна реализация и второго потребителя вне HTTP не
предвидится; правила направления зависимостей транспорту знать адаптер хранилища
разрешают.
### Р8. Способ представиться без прокси — развилка
Подставной провайдер уходит вместе с протоколом, и его место пусто. На это место
опирается `dev-run-task`, которая обещает адрес, по которому открывают
приложение. Варианты:
| Способ | Что даёт | Цена |
| --- | --- | --- |
| **Один main-пакет оснастки с подкомандами**`cmd/devtools proxy` ставит заголовок и переправляет запрос сервису, `cmd/devtools admin` заводит владельца панели | Приложение открывается браузером обычным образом; сервис остаётся без единой ветки для разработчика; строка исключения в сборке образа остаётся одна навсегда | Соседняя задача `dev-run-task` планировала свой пакет `cmd/devadmin` — её придётся переформулировать под подкоманду |
| **Отдельный main-пакет на каждый инструмент** — как было с подставным провайдером | Ничего не надо согласовывать с соседней задачей | Каждый инструмент стоит четырёх мест: строка сборки образа, «Деплой» в устройстве, «Команды» в памятке, `README`. Забытая строка сборки тихо кладёт инструмент разработчика в боевой образ, а инструментов сразу становится два |
| **Контейнер с прокси вместо своего кода** — контур и так стоит на Caddy, docker и так требование к машине разработчика | Каталог точек входа не растёт ни на один пакет | Ещё один контейнер в локальном прогоне и конфиг прокси, который никто не проверяет; отладка «почему не узнан» уходит в чужой контейнер |
| **Ключ конфига, подставляющий имя** | Один процесс вместо двух | В боевом коде появляется ветка, пускающая без всякой проверки; от беды её отделяет только правильность конфига на сервере. Сверх того — **прямо противоречит требованию дельты**: сервис узнаёт пришедшего по заголовку с доверенного адреса и никак иначе, значит цена включает переоткрытие спеки |
**Решено человеком на чекпоинте 2026-08-22:** первый вариант — один пакет
оснастки `cmd/devtools` с подкомандами. Эта работа заводит пакет и подкоманду
`proxy`; подкоманду `admin` заводит задача `dev-run-task`, и её запись надо
переформулировать — она планировала свой отдельный пакет. Строка исключения в
сборке образа остаётся одна навсегда.
## Risks / Trade-offs
- **Весь барьер держится на том, что прокси заголовок перезаписывает, а не
пропускает пришедший.** Запрос от анонима приходит к сервису с адреса прокси,
то есть с доверенного; если прокси не заменит `Remote-User` своим значением, а
оставит присланный, сервис пустит кого угодно под любым именем. Проверить это
репозиторием нечем — правило живёт в `pet-project-server`. → Требование к
контуру называется поимённо в модели угроз, и выкладку запускает человек.
- **Правило Authelia на домен сервиса не заведено.** Тогда прокси заголовка не
ставит вовсе, сервис никого не узнаёт, и все адреса приложения отвечают `401`.
→ Отказ громкий и одинаковый для всех, а перечень доверенных адресов сервис
называет строкой журнала при подъёме.
- **Переименование пользователя в Authelia заводит новую учётную запись**, и
прежние записи остаются у прежней. → Принятая цена, названная в постановке;
записывается в спеку, а не обходится.
- **Два первых обращения одним именем одновременно.** Оба не найдут записи и оба
примутся её заводить. → Уникальный индекс отвергает второго; код на отказ
уникальности повторяет поиск, а не падает.
- **Учётная запись заводится анонимом, если прокси настроен неверно.** Прежде
запись заводилась только успешным входом у провайдера. → Тот же риск, что и
первый, и то же смягчение: заводит её обращение с доверенного адреса, а
доверенный адрес — это прокси.
- **Перечень доверенных адресов, накрывающий весь интернет**, читается как
подсеть и стартует молча. Старт роняется на пустом и на нечитаемом перечне, но
«доверять всякому» остаётся достижимым настройкой — тем самым, что дизайн
отверг решением. → Смягчения барьером нет намеренно: порог «эта подсеть
слишком широка» обходится двумя половинками той же подсети, то есть был бы
барьером на вид. Наблюдаемость вместо барьера: перечень называется строкой
журнала при подъёме.
- **Логин у провайдера переиспользуем**, и новый его владелец получает архив
прежнего. → Записывается ценой в спеку и в модель угроз; не допускать
переиспользования — работа провайдера. Подробно — Р5а.
- **Панель владельца остаётся вне разграничения.** Она и была вне его; барьер
переезжает с правила на литерал пути (обходимого подменой знака) на домен,
закрытый Authelia. → Обход `/%5f/` перестаёт существовать, но канонизация пути
этой работой не делается, и в модели угроз это надо переписать, а не вычеркнуть.
## Migration Plan
Стройка: на сервере данных нет, выкладка пойдёт с чистого листа, переносить
нечего.
1. Новый шаг схемы — колонка ключа `provider_login` с уникальным индексом,
необязательная почта, снятые настройки OAuth2 и **все правила доступа
коллекции пользователей, снятые в пустое**. Применённый
`202608120001_oidc_login` не трогается.
2. Конфиг: секция входа переписывается, образец — вместе с ней.
3. Контур: правило прокси и правило Authelia для домена сервиса заводятся в
`pet-project-server`. Это делает человек, и до этого сервис никого не узнаёт.
Откат: шаг схемы возвращает то, что было до него, кроме открытого создания
записи — его не возвращает и прежний шаг, по той же причине.
## Open Questions
Открытых не осталось: оба вопроса закрыты человеком на чекпоинте 2026-08-22 —
имя ключа настроек (Р4) и способ представиться без прокси (Р8).