Files
transcriber/openspec/changes/archive/2026-08-22-trusted-header-login/design.md
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

362 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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).