Files
transcriber/openspec/changes/archive/2026-08-23-config-test-headers-login/design.md
T
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00

362 lines
31 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
Сервис не ведёт входа сам: кто пришёл, называет обратный прокси заголовками
`Remote-User`, `Remote-Name` и `Remote-Email`, а сервис верит им, когда
соединение открыто с адреса из перечня `[auth] trusted_proxies`. Имена
заголовков объявлены константами транспорта в
`internal/controller/http/identity.go`, и там же записано, что настройкой их не
делают. Барьер описан в `docs/security.md`, «Периметр», и нормирован спекой
`access`.
На машине разработчика прокси нет, браузер заголовков не ставит, и приложение
локально не открылось бы вовсе. На это место сегодня встаёт подставной прокси
`go run ./cmd/devtools proxy`: отдельный процесс, слушающий свой порт, ставящий
три заголовка и переправляющий запрос сервису. Локальный запуск поэтому идёт в
два процесса, приложение открывают по адресу прокси, а перечень доверенных
адресов в конфиге приходится переставлять на петлевой.
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
Изменение переносит подстановку заголовков в сам сервис и закрывает её
предохранителем.
Ограничение, из которого читается всё остальное: **подстановка заголовков — это
настройка, которой сервис называет пришедшего сам, никого не спросив.** Всякий,
до кого она дотянется, получит чужой архив. Дизайн поэтому наполовину состоит из
того, что подстановке запрещено.
## Goals / Non-Goals
**Goals:**
- Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
настроек, и ничем больше.
- Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
- Настройка, при которой отладочный вход становится открытым входом, роняет
старт, а не работает молча.
- Отладочный запуск виден владельцу в журнале.
**Non-Goals:**
- Уровень журнала, тексты внутренних отказов в ответах, подмена распознавателя,
снятие ограничителя частоты, послабления проверок конфига — ничего этого новый
признак не включает (см. решение 7).
- Своего входа, ролей и проверки допуска сервис по-прежнему не заводит:
подстановка называет пришедшего, а не судит его.
- Автотесты и разработческие сценарии, которым нужна учётная запись, продолжают
ставить заголовок сами и в подстановке не нуждаются.
- Граница работы — заголовки входа. Подмена распознавания в конфиг не
переезжает: `internal/adapter/recognizer/memory.go` подставляется правкой
кода, и решением владельца так и остаётся.
## Decisions
### 1. Где подставляются заголовки: отдельный слой перед узнаванием
**Решено:** отдельный слой цепочки корня приложения, стоящий **перед**
`TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
запроса и ничего больше не делает: учётной записи не заводит, отказов не
выдаёт, в контекст не пишет. Узнавание получает запрос, неотличимый от
пришедшего с сервера.
Слой собирается только при включённом предохранителе и непустой имитации. При
выключенном — цепочка та же, что сегодня, без единого лишнего звена.
Рассмотрено и отвергнуто:
- **Подстановка внутри `TrustedHeaderIdentity`** — узнавание получило бы второй
источник значений и ветку, которой в бою нет. Отлаживалась бы не боевая ветка,
а её отладочный двойник, и это ровно то, чего изменение обязано избежать.
- **Подстановка в точке входа, до сборки цепочки** — подставлять нечего: значения
ставятся на запрос, а не на сервис.
- **Слой снаружи ограничителя частоты** — ограничитель считает по адресу
спрашивающего, и заголовки входа его не касаются. Ставить слой снаружи значит
трогать порядок, который сегодня верен, без единой причины.
Что человек увидит иначе: приложение открывается по адресу сервиса, второго
порта нет, и в журнале запроса стоят те же строки узнавания, что на сервере.
### 2. Область слоя: только корень приложения
Слой вешается туда же, где висит узнавание, — на цепочку корня приложения, — и
область его выводится из того же перечня адресов, а не перечисляется вторым
списком. Проба здоровья, метрики и раздача приложения подстановки не видят: там
учётной записи нет и не нужно.
### 3. `debug = false` при непустой имитации: отказ старта
**Решено:** старт роняется с именем ключа. Не предупреждение и не молчаливое
игнорирование.
Причина: состояние «имитация заполнена, предохранитель выключен» не читается
никак, и обе его прочтения — поломка.
- Человек забыл включить предохранитель. Сервис поднимется, никого не узнает, и
человек будет искать поломку во входе, в прокси и в перечне адресов — везде,
кроме одного булева ключа. Предупреждение в журнале его не спасёт: журнал
локального запуска читают тогда, когда уже что-то сломалось.
- Человек забыл убрать имитацию из боевого файла. Тогда в выкладке лежит конфиг,
которому недостаёт одного слова, чтобы открыть архив всем. Отказ старта — тот
случай, когда поломку ловят на порядок раньше, чем она стоит денег.
Рассмотрено и отвергнуто:
- **Молчаливое игнорирование** — прямо против тона проекта: инвариант «Принятая
запись не теряется молча» описывает ту же болезнь на соседнем предмете.
- **Предупреждение в журнале** — оставляет заполненную имитацию в боевом файле
жить сколько угодно.
Обратная сторона: `debug = true` при пустой имитации отказом **не** считается.
Это законный запуск — признак сам по себе ничего не включает.
### 4. `debug = true` в бою: чем опасно и что ограничивает подстановку
Опасность названа моделью угроз прямо: всякий, кто дотянулся до сервиса,
называет себя кем угодно и получает чужой архив. Подстановка делает это хуже
барьера `trusted_proxies` по двум причинам. Первая: подставляется по
**отсутствию** заголовка, то есть запрос, пришедший мимо прокси, — именно тот,
который сегодня остаётся неузнанным, — получит имя. Вторая: требование к Caddy
«заголовки `Remote-*` перезаписывать, а не пропускать» здесь не помогает вовсе,
потому что перезаписывать нечего.
**Решено — одно ограничение: подставленный заголовок проходит тот же барьер,
что и настоящий.** Слой подставляет значения только тогда, когда адрес
соединения попадает в
`trusted_proxies`. Судит он его **той же функцией**, какой судит узнавание:
разбор адреса пира и сверка с перечнем берутся из одного дома
(`internal/controller/http/identity.go`), а не пишутся вторым списком, — так же,
как решение 6 поступает с именами заголовков. Второй сверщик разошёлся бы с
первым молча: узнавание разворачивает IPv4 в оболочке IPv6, и слой, написавший
свою сверку, отличался бы от него ровно на тех адресах, ради которых заводится.
Это не предохранитель, а согласованность с барьером узнавания: от конфига она не
требует ничего и круга тех, кто мог назваться кем угодно, не расширяет — он и
так очерчен перечнем.
**Адресного предохранителя не делаем.** Решение владельца на чекпоинте,
дословно: «предохранитель по адресам не делаем, полагаемся только на параметр
debug». Рассматривалось требование, чтобы при включённом предохранителе перечень
доверенных адресов состоял только из петлевых записей; оно снято вместе с
предикатом «петлевая запись», который заводился ровно ради него.
Что этим куплено: отладочный вход работает **внутри контейнера** — адрес пира
там принадлежит сети докера, и она же стоит в боевом перечне, — а локальный
прогон не переставляет перечень доверенных адресов на петлевой: петлевые записи
добавляются к тем, что в нём уже стоят.
Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, — а
доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
пришедший обычным путём**. Между боевой выкладкой и открытым входом остаётся
три вещи, и других нет: умолчание предохранителя «выключено»; отказ старта при
заполненной имитации без предохранителя (решение 3); боевой конфиг, который
рендерится шаблоном Ansible, а не копируется с машины разработчика.
Что человек увидит иначе: отладочный вход работает везде, где адрес соединения
попадает в перечень доверенных, — и на машине разработчика, и внутри
контейнера. Перечень при этом остаётся тем, что он есть: границей доверия, а не
признаком отладки.
Рассмотрено и отвергнуто:
- **Требовать петлевой перечень при включённом предохранителе** — снято
решением владельца, причина выше. Держалось предикатом «петлевая запись»,
который заводился ради этой одной проверки.
- **Принудительно слушать петлевой адрес при включённом предохранителе** —
меняет поведение молча (адрес прослушивания задан числом порта, и человек
увидел бы «порт занят никем»), и закрывает ровно то, что решение покупает:
внутри контейнера сервис слушает не петлю.
- **Новый ключ `[server] listen`** — публичная поверхность настроек ради
предохранителя, которого решением владельца нет.
- **Вырезать подстановку из боевой сборки тегом сборки** — сборка образа в гейте
не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег, забытый в одной ступени,
дал бы ровно ту тишину, которой избегает пункт 3.
### 5. Уже пришедший заголовок: подставляем только при отсутствии `Remote-User`
**Решено:** решение принимает **наличие `Remote-User`**, а не его значение.
Заголовок есть в любом числе значений — слой не трогает запрос вовсе и передаёт
его дальше как есть. Заголовка нет ни одного — слой ставит всю тройку
`Remote-*`, которой распоряжается: названные имитацией — значением из конфига,
не названные — удаляет.
Почему по наличию, а не по значению: узнавание отвергает запрос с более чем
одним `Remote-User`, и это его главная защита от прокси, который заголовок
добавляет вместо замены. Слой, который «дополнил бы пустое место», сам создал бы
второе значение и превратил бы законный отказ в отладочный проход.
Почему удаляем не названные имитацией: запрос без `Remote-User`, но с
`Remote-Email`, иначе собрал бы личность из конфига и из присланного — логин
свой, почта чужая. Слой владеет тройкой целиком или не трогает её вовсе.
Рассмотрено и отвергнуто:
- **Заменять всегда** — сервис, стирающий сказанное прокси, перестаёт быть
похожим на боевой, и ветки «пришло два значения» и «заголовок с недоверенного
адреса» становятся локально невоспроизводимыми.
- **Подставлять по пустому значению `Remote-User`** — пустое значение штатно
шлёт прокси там, где никого не назвал; подстановка на этом месте назвала бы
человеком того, кому провайдер отказал.
### 6. Имена заголовков: ключами конфига, но перечень порождается константами
Цена названа прямо: сегодня имена — константы транспорта, и у них один дом.
Секция, где имя заголовка служит именем ключа, даёт этим именам **второе
упоминание**, а второе упоминание расходится с первым молча.
**Решено:** имя ключа секции — имя заголовка, но набор принимаемых имён
порождается теми же константами транспорта. Ключ, не совпавший ни с одной из
них, роняет старт и называет принимаемые имена. Сравнение идёт по каноническому
виду имени заголовка, потому что в HTTP имя нечувствительно к регистру, а в TOML
ключ чувствителен.
Так второго **дома** не появляется: дом остаётся один, а конфиг лишь называет
его содержимое, и совпадение сверяет машина, а не внимательность.
Рассмотрено и отвергнуто:
- **Произвольная карта имён** — опечатка `Remote-Usr` даёт «сервис меня не
узнаёт» без единого следа, а выгоды нет: сервис читает ровно три имени и
четвёртое читать не умеет.
- **Свои имена ключей вместо имён заголовков** (`login`, `name`, `email`) — самое
дешёвое для инварианта, но теряет то, ради чего секция заводится: конфиг
перестаёт выглядеть как то, что ставит Caddy, и владелец назвал форму прямо.
Оставлено вариантом на случай, если человек решит платить иначе.
Замечание по форме: в TOML пара пишется через `=`, а не через `:`. Пример
владельца записывается так:
```toml
[auth.test_headers]
Remote-User = "foobar"
Remote-Email = "foobar@example.com"
```
### 7. Что означает `[server] debug` и чего он не включает
Признак означает одно: **этот прогон идёт на машине разработчика, и сервису
позволено подставить то, что в бою даёт окружение.** Сегодня подставляется
ровно одна вещь — заголовки входа.
Чего признак **не** включает и включать не должен без отдельного решения
человека:
- уровень журнала (он зашит и настройкой не является);
- тексты внутренних отказов и следы стека в ответах;
- вывод тел запросов и ответов внешних сервисов;
- снятие или послабление ограничителя частоты;
- подмену распознавателя памятью — это подстановка в коде,
`internal/adapter/recognizer/memory.go`, и настройкой она не делается;
- послабление любой проверки старта;
- открытие любого адреса неузнанному.
Правило для следующей задачи: новое поведение вешается на этот ключ только
решением владельца и получает **свою** строку в спеке `access` или в спеке своей
capability. Ключ — предохранитель с закрытым перечнем следствий, а не режим с
открытым.
### 8. Где живёт проверка
Проверка охватывает две секции разом — `[server] debug` против
`[auth] test_headers` и `[auth] trusted_proxies`, — и потому не помещается ни в
`AuthConfig.Validate()`, ни в проверку секции сервера. **Решено:** метод на
корневой `Config`, названный своим предметом, зовётся из `cmd/transcriber` рядом
с тремя имеющимися проверками. Закрыть расхождение «единого места проверки нет»,
названное в `docs/conventions/config.md`, он не пытается: это отдельная работа.
Рассмотрено и отвергнуто: передавать признак отладки внутрь
`AuthConfig.Validate()` — секция начала бы знать о чужой секции ради одного
булева значения.
### 9. Журнал
- **Старт с включённой подстановкой** — уровень `WARN`, один раз. Адресат —
владелец, и сообщение ровно того рода, который конвенция называет «может стать
проблемой»: сервис называет пришедшего сам. Поля: `capability` `access`,
перечень **имён** подставляемых заголовков. Значений в строке нет: логин — это
ключ к чужому архиву, и спека `access` запрещает его печатать наравне с
адресом почты.
- **Запрос, которому заголовки подставлены** — уровень `DEBUG`. Случай штатный и
частый: так выглядит каждый запрос локального прогона, и `INFO` затопил бы
журнал. Поля те же, что у узнавания: `http.peer_addr`, `capability`,
`transport`.
- **Запрос, которому подставить нельзя** (предохранитель включён, но адрес пира
не доверенный) — уровень `WARN`, своей строкой. Поля: `http.peer_addr`,
`capability`, `transport`; подставляемых значений в ней нет.
Прежняя посылка «узнавание уже пишет здесь предупреждение» неверна, и это
проверено по коду. Ветка узнавания выбирается по числу значений `Remote-User`
**до** сверки адреса пира: заголовка нет — пишется `Debug("Request carries no
login header")`, и запрос уходит дальше. Предупреждение `Login header came
from an untrusted peer` достижимо только тогда, когда заголовок **есть**, а
подстановка работает ровно при его отсутствии. На отказном пути подстановки
предупреждения не бывает никогда.
Без своей строки самый частый локальный отказ остаётся без следа: браузер идёт
на `localhost`, тот разрешается в `::1`, а в `trusted_proxies` по образцу
стоит `127.0.0.1`. Человек видит `401` на всём приложении, заполненную секцию
имитации, включённый предохранитель — и ни одной строки о том, что подстановка
не сработала. Отсюда же требование к образцу конфига: рядом с `127.0.0.1` в
перечне обязан стоять `::1`.
- Строка `Account created from login header` работает как работала: первый
запрос локального прогона заводит учётную запись и виден на `INFO`.
### 10. Подкоманда `cmd/devtools proxy` убирается целиком
**Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
входить локально не остаётся тоже.
Цена названа и принята: поломки контура — два значения `Remote-User`, заголовок
с недоверенного адреса, цепочка `X-Forwarded-For` — местным инструментом больше
не воспроизводятся. Воспроизводит их теперь только автотест, ставящий заголовок
сам.
Уходит вместе с подкомандой: её флаги, шапка пакета `cmd/devtools/main.go` — она
сегодня говорит «подкоманд две» и описывает прокси, — строка подкоманды в
`usage()` и упоминания прокси в документах. Адреса перечислены шагом 4.4
`tasks.md`.
Рассмотрено и отвергнуто владельцем:
- **Оставить как есть** — два способа войти локально, и документация обязана
каждый раз говорить, какой из них чей.
- **Оставить, сузив назначение** до воспроизведения поломок контура — тот же
второй способ, только не названный в инструкции локального запуска.
## Risks / Trade-offs
- **Отладочный вход уезжает в боевой конфиг** → умолчание предохранителя
«выключено» и отказ старта при заполненной имитации без предохранителя
(решение 3). Проверка идёт на старте, до приёма трафика, и ловит она ровно
этот случай — заполненную имитацию при выключенном предохранителе.
- **Разработчик оставил `debug = true` и выложился** → выкладка рендерит конфиг
из шаблона Ansible, а не копирует локальный файл. Другой защиты нет, и это
принято решением владельца (решение 4): боевой сервис с включённым
предохранителем и заполненной имитацией отдаст архив всякому, чей запрос
пришёл через обратный прокси, то есть всякому, кто пришёл обычным путём.
- **Имя заголовка в конфиге разошлось с константой транспорта** → перечень
принимаемых имён порождается константами, неизвестный ключ роняет старт
(решение 6).
- **Поломки контура перестали воспроизводиться локально** → подкоманда `proxy`
убрана (решение 10); остаются автотесты, которые ставят заголовок сами.
- **Ключ `debug` обрастёт чужими следствиями** → закрытый перечень в решении 7 и
правило «новое следствие — своя строка в спеке».
- **Тестовый пользователь копится в базе.** Каждый новый логин в имитации заводит
учётную запись, а удалять учётные записи сервис не умеет. Локальная база
ронется и пересоздаётся свободно; в бою подстановка выключена, а включённая
была бы поломкой много хуже лишней учётной записи.
## Migration Plan
Шага схемы изменение не требует, данных не трогает. Существующий конфиг
продолжает работать без правки: умолчание предохранителя — «выключено», секция
имитации по умолчанию пуста, и цепочка слоёв при этом та же, что сегодня.
Откат — удаление двух ключей из конфига.
## Open Questions
Развилок не осталось: все закрыты владельцем на чекпоинте.