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