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

36 KiB
Raw Blame History

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).