- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
36 KiB
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
Стройка: на сервере данных нет, выкладка пойдёт с чистого листа, переносить нечего.
- Новый шаг схемы — колонка ключа
provider_loginс уникальным индексом, необязательная почта, снятые настройки OAuth2 и все правила доступа коллекции пользователей, снятые в пустое. Применённый202608120001_oidc_loginне трогается. - Конфиг: секция входа переписывается, образец — вместе с ней.
- Контур: правило прокси и правило Authelia для домена сервиса заводятся в
pet-project-server. Это делает человек, и до этого сервис никого не узнаёт.
Откат: шаг схемы возвращает то, что было до него, кроме открытого создания записи — его не возвращает и прежний шаг, по той же причине.
Open Questions
Открытых не осталось: оба вопроса закрыты человеком на чекпоинте 2026-08-22 — имя ключа настроек (Р4) и способ представиться без прокси (Р8).