локальный вход задаётся конфигом: заголовки подставляет сам сервис

- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
This commit is contained in:
av
2026-08-23 13:12:47 +03:00
parent 75c6f0168a
commit 52fe31319a
35 changed files with 3548 additions and 144 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-23
@@ -0,0 +1,361 @@
## 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
Развилок не осталось: все закрыты владельцем на чекпоинте.
@@ -0,0 +1,74 @@
## Why
Чтобы открыть приложение на своей машине, сегодня мало запустить сервис: рядом
надо поднять второй процесс — подставной обратный прокси из оснастки, — потому
что сервис узнаёт пришедшего по заголовку, а браузер заголовков не ставит. Два
процесса вместо одного, свой порт у каждого и правило «приложение открывают по
адресу прокси, а не по адресу сервиса» — всё это надо помнить и объяснять
каждому, кто пришёл в проект.
Владелец назвал желаемое прямо: один бинарник, а различия между запусками
задаются конфигом. Тестовый прогон должен подниматься тем же
`go run ./cmd/transcriber -c config.toml`, что и всякий другой, и отличаться от
боевого только содержимым файла настроек.
## What Changes
- В секцию `[auth]` добавляется подсекция `test_headers` — имитация заголовков,
которые сервису на сервере ставит обратный прокси. Ключи подсекции задают
значения, которыми сервис назовёт пришедшего сам, когда тот пришёл ни с чем.
- В секцию `[server]` добавляется предохранитель `debug` — признак отладочного
запуска со значением по умолчанию «выключено». Подстановка заголовков работает
только при включённом признаке.
- Старт получает новые правила отказа. Заполненная имитация при выключенном
предохранителе роняет сервис, а не игнорируется молча; имитация, при которой
узнавание не состоится ни при каком запросе — неизвестное имя заголовка,
отсутствующий или негодный `Remote-User`, — роняет его тоже.
- Владелец обязан видеть отладочный запуск в журнале сервиса — предупреждением
при старте и на каждом подставленном запросе.
- Подставной обратный прокси `go run ./cmd/devtools proxy` убирается целиком:
всё, ради чего его поднимали, делает сам сервис, и второго способа входить
локально не остаётся. Решение владельца на чекпоинте; цена — поломки контура
местным инструментом больше не воспроизводятся.
- Образец конфига и памятка описывают локальный вход через настройки вместо
запуска второго процесса.
## Capabilities
### New Capabilities
Новых capability изменение не заводит: предмет прежний — кто пришёл в сервис и
пускают ли его дальше.
### Modified Capabilities
- `access`: узнавание получает второй источник заголовков — настройки сервиса —
и правила, при которых этот источник законен. Требования: подстановка идёт
только при включённом предохранителе, только при отсутствии пришедшего
заголовка, только с адресов, которым сервис и так верит; заполненная имитация
без предохранителя роняет старт; отладочный запуск виден в журнале, а значения
подставленного заголовка в журнале по-прежнему нет.
## Impact
- Настройки: `internal/config` — структуры секций `[server]` и `[auth]`,
умолчания, проверки старта. Имя нового ключа конфига необратимо и решается
человеком.
- Вход: `internal/controller/http` — цепочка слоёв корня приложения и
узнавание по заголовку.
- Точка входа: `cmd/transcriber` — порядок проверок при старте и сборка
цепочки.
- Оснастка: `cmd/devtools` — подкоманда `proxy` удаляется вместе с флагами,
шапкой пакета и строкой `usage()`.
- Документы: `config.example.toml`, `CLAUDE.md`, `README.md`,
`docs/security.md`, `docs/conventions/config.md`, `docs/architecture.md`
везде, где локальный вход описан через второй процесс.
- Граница домена: `docs/passport.md`, «Управление учётными записями». Сегодня
он утверждает, что кто пришёл, сервис не решает никогда, и что исключений у
этого больше нет. Изменение заводит исключение, и паспорт обязан назвать его
одной строкой с границей — предохранитель `[server] debug`, выключенный по
умолчанию, — и ссылкой на спеку `access`.
- Периметр: изменение заводит настройку, которой сервис пускает кого угодно.
Машиной боевая поломка не исключена: сервис с включённым предохранителем и
заполненной имитацией поднимется на любом перечне доверенных адресов. Что
стоит между боевой выкладкой и открытым входом — в `design.md`, решение 4.
@@ -0,0 +1,725 @@
# Триаж ревью — `config-test-headers-login`, 2026-08-23
## Сводка
- **Режим прогона:** по графу. Стадия — ревью кода.
- **База диффа:** `origin/master` совпадает с `HEAD`; предмет ревью — рабочее
дерево, включая неотслеживаемые файлы.
- **Размер:** 16 изменённых файлов + 4 новых (`test_headers.go`,
`test_headers_test.go`, `substitute.go`, `substitute_test.go`), ~240 строк
правок в коде; удалена подкоманда `proxy` пакета `cmd/devtools`.
- **Сложность:** новый слой транспорта, новая подсекция конфига с проверкой
старта, снятая оснастка; трогает барьер входа.
- **Метка:** `large`. Обоснование разметки — своих тем проекта сверх ядра не
заведено, поэтому `basics` не запускается: все темы разобраны именными
проходами.
- **Состояние гейта:** ЗЕЛЁНЫЙ. `task gate BASE=origin/master`, 13 шагов,
`go test -race` с работающим детектором, `golangci-lint` 0 issues,
`govulncheck` без находок. Сообщено проходом `autotests`; триаж гейт
не перезапускал.
- **Находок на входе:** 21 (A1; S1S4; C1C5; R1R3; V1V3 + три свойства
`adversary` без пути; O1). **Осталось в первых двух секциях:** 7.
### План с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| `requirements` | `openspec/specs/access/spec.md` + дельта | разбор | `specs` | **закрыта** проходом `specs`, 4 находки (S1S4) |
| `autotests` | `CLAUDE.md`, «Гейт» | — | `autotests` | **закрыта** проходом `autotests`, 1 находка (A1), гейт зелёный |
| `conventions` | `docs/conventions/` целиком | разбор | `code` | **закрыта** проходом `code`, 5 находок (C1C5) |
| `architecture` | `docs/architecture.md` + источник `docs/passport.md` | доказательство | `architecture` | **закрыта** проходом `architecture`, 3 находки (R1–R3) + 2 замечания «дешевле до мерджа» |
| `security` | `docs/security.md`, «Периметр» | доказательство | `adversary` | **закрыта** проходом `adversary`, 3 находки (V1–V3) + 3 свойства без построенного пути; пути обхода **самой подстановки** не построено (6 отрицательных проб) |
| `operations` | `docs/architecture.md`, «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта** проходом `ops`, 1 находка (O1) + подтверждённый факт по постмортему 2 |
| тема проекта | своих тем нет | — | `basics` не запускался | **дома у темы нет**; `basics` на метке `large` не запускается по плану — это не пропуск |
**Тем без отчёта нет.** Все шесть заявленных домов дали отчёт.
### Сигнал о заниженной метке
**Пришёл от одного прохода, возражений нет.** `review-code` метку `large`
подтвердил и занижения не увидел. `review-basics` на этой метке не запускался,
поэтому второго независимого голоса нет — согласия двух проходов здесь не было
и быть не могло.
### Находка о самом прогоне
**Ни один из шести проходов не объявил свой потолок.** Устав велит называть
строкой, сколько находок показано, каков был потолок и что осталось за срезом;
такой строки нет ни у `specs`, ни у `code`, ни у `architecture`, ни у
`adversary`, ни у `ops`, ни у `autotests`. Это ровно тот повторяющийся пробел,
который записан в [docs/review.md](../../../../docs/review.md) строками 25–32 по
прогону `telegram-enabled-flag`: «находок больше нет» в отчёте прохода
неотличимо от «больше не поместилось». Механизации нет; сверка триажа —
единственное, что осталось, и она этот пробел только называет, а не закрывает.
### Дедупликация
- `A1` + `S2` — одна причина (строка журнала старта строится непроверенной
функцией). Сведены; **понижены**: триаж добыл им однократный оракул прогоном
бинарника, см. ниже.
- `S3` + `O1`**две причины, а не одна**, вопреки подсказке задания: первая —
отброшенные метаданные разбора (`MetaData.Undecoded()`); вторая — зашитый
уровень журнала. Разведены в находки 1 и 2: чинятся порознь, и починка одной
не закрывает другую.
- `S4` + второе звено `O1` — одна причина (зашитый `LevelInfo`). Сведены в
находку 1.
- `R3` + замечание `specs` про два места — одна причина. Сведены в находку 6
вместе с `C1`: обе легли на одну строку `server.go:44`.
- `R1` + `R2` — разные причины в разных домах, сведены в одну находку 7 двумя
названными правками: обе — один абзац, обе про то, что нормативный документ
описывает изъятие мягче, чем оно есть.
- **Согласие проходов приоритет поднимало, `Confidence` — нет.** Ни одна
формулировка ниже не опирается на «подтверждено N проходами».
---
## Блокирует мердж
### 1. Каждый отладочный след нового входа пишется уровнем, который в настоящем прогоне выключен: разработчик получает `401` на всём приложении и пустой журнал
- Файл: `cmd/transcriber/main.go:39-41`; `internal/controller/http/substitute.go:97-99`;
`internal/controller/http/identity.go:106-110`
- Severity: major
- Confidence: high
- Оракул: **свой прогон настоящего бинарника.**
`go build -o $S/transcriber ./cmd/transcriber`, конфиг из `config.example.toml`
с `debug = true`, `trusted_proxies = ["127.0.0.1", "::1"]` и правильной секцией
`[auth.test_headers]`, запрос `curl http://127.0.0.1:18099/app/me``200`.
В журнале — стартовая `WARN "Identity headers are substituted from configuration" headers=[Remote-User]`
и `INFO "Account created from login header"`; **строки
`"Identity headers substituted from configuration"` нет ни одной**.
Второй прогон, тот же конфиг с опечаткой в имени секции → `код=401`, и в
журнале ровно `INFO "Incoming request" ... http.status_code=401` без единой
строки о причине.
Подтверждающая команда:
`grep -rn "\.Debug(" --include=*.go internal/ cmd/ | grep -v _test` → 6 мест;
`grep -rn "LevelDebug\|Level:" --include=*.go cmd/ internal/ | grep -v _test`
→ единственная точка сборки логгера `cmd/transcriber/main.go:40`
`Level: slog.LevelInfo`, ключа конфига у уровня нет, `os.Getenv`/`os.LookupEnv`
в непроверочном коде нет вовсе.
- Последствие: **все шесть отладочных строк сервиса недостижимы в любом
настоящем прогоне** — и обе, которые завела эта задача. Дельта-спека при этом
требует отладочную строку прямо (`specs/access/spec.md:193`: «Запрос, которому
заголовки подставлены, SHALL писаться на отладочном уровне») и в том же файле,
строка 394, сама формулирует цену: «поломка, записанная уровнем, который в бою
выключен, не записана вовсе». Критерий приёмки 9 — различимость «узнан
подстановкой» от «узнан прокси» на уровне запроса — в действительности не
достигнут ничем. Второе звено дороже первого: `identity.go:106` пишет «Request
carries no login header» тоже отладочным уровнем, а путь «заголовка нет,
предохранитель не включён» стал **единственным путём первого локального
запуска у каждого** после удаления подкоманды `devtools proxy`. Человек видит
`401` на всём приложении и пустой журнал — то самое «без единого следа», ради
устранения которого заведены отказы старта.
- Предложение: развилка ниже.
- Найдено проходами: `specs` (S4), `ops` (O1, второе звено); оракул добыт триажем
заново.
- **Действие: развилка.**
> Отладочные строки сервиса не печатаются никогда: уровень журнала зашит
> `LevelInfo` в единственной точке сборки логгера, ключа конфига у него нет.
> Спека этой задачи требует отладочную строку подстановки, и критерий 9 на ней
> стоит. Что делаем?
>
> 1. **Завести ключ уровня журнала** (`[server] log_level` либо уровень,
> выводимый из `[server] debug`). Спека прямо запрещает предохранителю
> трогать уровень журнала, значит это отдельный ключ и правка спеки
> `access` — расширение scope и новое имя ключа конфига, а имя ключа
> `CLAUDE.md` относит к необратимому и требует спросить человека.
> 2. **Поднять уровень двух строк до `Info`** — «подстановка сработала» и
> «заголовка входа нет». Обе штатны и частотны; в бою первая не печатается
> вовсе (предохранитель выключен), вторая станет строкой на каждый
> неузнанный запрос. Правка спеки нужна тоже — она уровень назначает
> поимённо.
> 3. **Снять требование отладочной строки из спеки и критерий 9 вместе с ним**,
> записав строкой цену: наблюдаемости подстановки на уровне запроса у
> сервиса нет, стартовой `WARN`-строки признано достаточно.
### 2. Опечатка в имени самой секции `[auth.test_headers]` не судится ничем: сервис поднимается, никого не узнаёт и следа не оставляет — ровно тот исход, ради которого заведён второй отказ старта
- Файл: `internal/config/config.go:242-256` (метаданные разбора отбрасываются);
`internal/config/test_headers.go:33-43`
- Severity: major
- Confidence: high
- Оракул: **свой прогон, два уровня.**
(1) Модульный: декодер `BurntSushi/toml v1.5.0` на входе
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"` даёт
`decode err=<nil>`, `undecoded=[auth.test_headrs auth.test_headrs.Remote-User]`,
`Server.Debug=true len(TestHeaders)=0 substituting=false`,
`ValidateTestHeaders err = <nil>`. Контроль: та же опечатка **внутри** секции
(`Remote-Usr`) ловится —
`auth: секция [auth.test_headers] называет заголовок, которого сервис не читает: Remote-Usr`.
(2) Сквозной: настоящий бинарник на конфиге с `[auth.test_headrs]` поднимается
без единого предупреждения, `curl /app/me``401`, в журнале только
`INFO "Incoming request" ... http.status_code=401`.
- Последствие: `design.md` заявляет защиту словами «опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа», и защита эта
покрывает опечатку в имени **ключа**, но не в имени **секции**. Разница для
человека нулевая — он ошибается в обеих строках одинаково, — а исход
противоположный: в одном случае отказ старта с именем ключа, в другом
безмолвный `401` на всём приложении. Стоимость несёт каждый первый локальный
запуск, потому что подкоманды `devtools proxy` больше нет и обходного пути не
осталось. Отдельно: та же дыра проглатывает любую опечатку в **любой** секции
конфига, не только в новой.
- Предложение: развилка ниже.
- Найдено проходами: `specs` (S3), `ops` (O1, первое звено); оракул добыт триажем
заново.
- **Действие: развилка.**
> `toml.DecodeFile` возвращает `MetaData`, и в нём лежит перечень ключей,
> которых структура не знает. Сервис его отбрасывает, поэтому опечатка в имени
> секции проходит молча. Что делаем?
>
> 1. **Узкая правка в границах задачи:** при `[server] debug = true` и пустой
> секции имитации ронять старт (или писать `WARN`), если
> `MetaData.Undecoded()` называет что-нибудь под `auth`. Ловит ровно этот
> класс, не трогает остальной конфиг, требует одного нового абзаца в
> дельта-спеке.
> 2. **Правило шире задачи:** судить `MetaData.Undecoded()` на всём конфиге —
> непонятый ключ роняет старт с его именем. Закрывает опечатки во всех
> секциях разом, но это новая норма в `docs/conventions/config.md` и
> отдельный change: сегодняшние конфиги на сервере могут нести ключи,
> которых структура уже не знает (постмортем 2 подтвердил, что откат
> бинарника поверх нового конфига сегодня безопасен **именно** потому, что
> лишние ключи молча игнорируются, — правило это свойство снимет).
> 3. **Ничего не делать в коде**, а записать исход строкой в спеке и в
> `config.example.toml`: «опечатка в имени секции даёт `401` без следа;
> сверяйте имя секции по образцу». Дешевле всего и честнее молчания.
### 3. Спека объявляет матрицу отказов старта полной и называет три случая, а код даёт четвёртый: следующий change снимет его как самодеятельность, и одно из двух значений секции начнёт теряться молча
- Файл: `internal/config/test_headers.go:86-93`;
`openspec/changes/config-test-headers-login/specs/access/spec.md:109`;
`openspec/changes/config-test-headers-login/tasks.md:227-231`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Вход
`[server] debug = true` + `[auth.test_headers]` с `"Remote-User" = "one"` и
`"remote-user" = "two"`: декодер даёт `len(c.Auth.TestHeaders) == 2`,
`HeaderSubstitution()` возвращает `map[Remote-User:two]` — **значение `"one"`
потеряно**, — а `ValidateTestHeaders` роняет старт строкой
`auth: в секции [auth.test_headers] два ключа называют один заголовок: имя
заголовка нечувствительно к регистру, и одно из значений потерялось бы молча`.
Дословный текст спеки на строке 109: «Отказом MUST быть **каждый из трёх**
случаев»; сценария про совпадение канонических имён в дельте нет. Дословный
текст `tasks.md:227`: «Матрица отказов старта **полна и тотальна**: у каждой
комбинации новых ключей объявлен исход».
- Последствие: реализатор, читающий спеку как полную матрицу — а `tasks.md`
велит читать её именно так, — снимет четвёртую проверку как не подпёртую
требованием. После снятия конфиг с двумя ключами разного регистра поднимает
сервис, схлопывает их в один и молча берёт тот, который последним лёг в карту:
порядок перебора TOML не гарантирован, и человек получает то одно значение, то
другое от запуска к запуску. Это в точности класс «молчаливая потеря
значения», ради которого проверка и написана. Правка целиком в границах
задачи: дельта-спека — часть этого change.
- Предложение: дописать четвёртый случай в требование «Настройка, открывающая
вход всем, роняет старт» и завести к нему сценарий; поправить `tasks.md:227`
так, чтобы перечисление читалось «предохранитель × (пустая / заполненная /
заполненная негодно / заполненная неоднозначно)».
- Найдено проходом: `specs` (S1); оракул добыт триажем заново.
- **Действие: инлайн.**
---
## Стоит исправить сейчас
### 4. Бинарник читает `.env`, проект называет его секретосодержащим, а `.gitignore` его не игнорирует: первый же ключ, положенный туда, уезжает в git необратимо
- Файл: `.gitignore` (нет записи); `.dockerignore:21`;
`cmd/transcriber/main.go:114-117`
- Severity: major
- Confidence: high
- Оракул: **свои команды.** `git check-ignore -v .env` → пусто, `exit=1` (файл не
игнорируется). `.dockerignore:19-21` — комментарий «Настройки с секретами.
Образ берёт конфиг на сервере, а не из дерева.» и следом строка `.env`.
`cmd/transcriber/main.go:115``godotenv.Load()`. Прогон настоящего бинарника
печатает при каждом старте
`level=WARN msg="Warning: .env file not found, using system environment variables"`.
При этом `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ | grep -v _test`
**ни одного совпадения**: переменных окружения сервис не читает нигде.
- Последствие: инвариант `CLAUDE.md` — «**Секрет не покидает конфиг.** Ключ
SpeechKit и пара ключей Object Storage не попадают в git… Нарушение
необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки.
**critical**». Путь к нарушению короткий и сервис сам его подсказывает:
стартовая `WARN`-строка сообщает владельцу, что `.env` ожидается, тот заводит
файл с ключами — и `git add .` кладёт его в индекс, потому что
`.gitignore` про `.env` не знает. Единственный барьер — `gitleaks git --staged`
на pre-commit, а он судит по образцам и на произвольном ключе SpeechKit молчать
вправе. Сама подсказка при этом ложная: ключи из `.env` не сработали бы, читать
переменные окружения сервису нечем. **`critical` не ставлю:** пути до
настоящего секрета в git я не построил — работающего мотива положить ключ в
`.env` у владельца нет, — значит по контракту выше `major` эта находка не
поднимается.
- Предложение: строку `.env` в `.gitignore` рядом с `config.toml` — это одна
строка и весь инлайн. Мёртвый `godotenv.Load()` вместе с зависимостью и
вводящей в заблуждение `WARN`-строкой уходит задачей: правка не этого change.
- Найдено проходом: `adversary` (свойство без построенного пути); оракул и
переформулировка — триаж (проход утверждал, что `.env` читается, но не заметил,
что читать из него некому).
- **Действие: инлайн** — только строка в `.gitignore`. Снятие `godotenv`
задачей, см. «Урожай».
### 5. Чужой адрес почты, занятый первым пришедшим, навсегда и молча оставляет без почты его настоящего владельца — и `docs/security.md` утверждает обратное
- Файл: `internal/controller/http/identity.go:134-138`;
`internal/adapter/repo/sqlite/identity.go:77-110`; текст
`docs/security.md:71-76`
- Severity: major
- Confidence: high
- Оракул: **свой падающий сценарий, прогнан на настоящей поверхности сервиса**
(`setupEnv` + `AppChain`, база SQLite во временном каталоге, маршрут
`/app/me`). Два шага:
1. Запрос с `Remote-User: squatter` и `Remote-Email: victim@corp.example` с
доверенного адреса → `200`. `SELECT email FROM users WHERE provider_login='squatter'`
`victim@corp.example`.
2. Запрос настоящего владельца: `Remote-User: victim`,
`Remote-Email: victim@corp.example``200`, `Account created from login header`.
`SELECT email ... WHERE provider_login='victim'`**`""`**. Строк журнала о
занятом адресе — ноль.
Второй прогон показывает асимметрию правила прямо: два значения `Remote-User`
`401`; два значения `Remote-Email``200`, и в базу ложится **первое**
(`attacker-first@example.com`).
- Последствие: `docs/security.md:71-74` называет прокси, **добавляющий** заголовок
вместо замены, главной угрозой периметра и пишет: «Половину этой беды сервис
закрывает сам: запрос с двумя значениями `Remote-User` не узнаёт никого».
Утверждение верно ровно для одного заголовка из трёх. `Remote-Name` и
`Remote-Email` берутся `Get()` — первым значением, то есть присланным анонимом,
— и логин от них не защищает. Уникальный индекс `idx_users_email` не даёт
двум записям делить адрес, `EnsureUser` найденную запись не переписывает
никогда, а ветвь отката заводит настоящего владельца через
`insertUser(tx, login, name, "")` **без строки журнала**. Порча тихая и
постоянная: обратного пути у неё в коде нет. **Сегодня цена почти нулевая**
`contract.UserAccount` поля `Email` не имеет вовсе, приложение почту не
показывает, доступ по ней не выдаётся; поэтому не блокирует мердж. Цена
появляется в тот день, когда почту начнут читать (в бэклоге лежит
`api-tokens`), и к этому дню колонка уже будет испорчена молча.
- Предложение: судить `Remote-Name` и `Remote-Email` тем же правилом, что
`Remote-User``len(r.Header.Values(name)) != 1` даёт пустое значение, а не
первое; ветвь отката уникальности почты обязана оставить строку журнала
видимого уровня.
- Найдено проходом: `adversary` (V1); оракул добыт триажем заново, обоими
звеньями.
- **Действие: развилка.**
> Правка меняет боевой путь узнавания, а не отладочную подстановку, — то есть
> выходит за scope этой задачи и требует нового требования в спеке `access`
> (сегодня спека нормирует многозначность только для `Remote-User`). Что
> делаем?
>
> 1. **Взять в этот change:** три строки в `identity.go` + строка журнала в
> ветви отката + абзац в требование спеки. Логика ровно та же, что слой
> подстановки уже применил к себе («Запрос без `Remote-User`, но с
> `Remote-Email` иначе собрал бы личность из двух источников»), — правило
> просто не донесено до соседних двадцати строк.
> 2. **Отдельной задачей**, потому что предмет другой и цена сегодня нулевая:
> почту никто не читает. Тогда `docs/security.md:71-74` надо поправить в
> этом мердже — фраза «половину этой беды сервис закрывает сам» описывает
> периметр точнее, чем он есть, и ссылаться на неё нельзя.
> 3. **Только документ:** записать асимметрию как принятую цену, потребовав от
> прокси перезаписи всех трёх `Remote-*` (требование к контуру уже записано,
> см. «Недоступно проверке»), и колонку почты не трогать.
### 6. Страж, отменяющий слой подстановки, не защищён ни одной проверкой и считает «подставляем ли» вторым выражением: убрать его можно молча, а разойтись он может сам
- Файл: `internal/controller/http/server.go:27-49` (строка 44
`if len(substitution) > 0`); `internal/config/test_headers.go:33-43`;
`internal/controller/http/substitute_test.go:229-246`
- Severity: minor
- Confidence: high
- Оракул: чтение с двумя дословными сопоставлениями.
(1) Комментарий `server.go:30-31` утверждает: «Судит эту пустоту точка входа:
перечень приходит сюда уже готовым», — а строка 44 судит её второй раз своим
выражением. Тождество двух формулировок (`c.Server.Debug && len(TestHeaders) > 0`
против `len(substitution) > 0`) сегодня держится только тем, что
`HeaderSubstitution` возвращает `nil` в ложной ветке; ничем другим оно не
закреплено.
(2) Единственная проверка на пустую имитацию — `TestEmptySectionLeavesHeadersUntouched`
— судит три вещи: код `401`, неизменное число учётных записей и отсутствие в
журнале строки `"Identity headers are not substituted"`. Строка эта пишется
**только** в ветке недоверенного пира (`substitute.go:80`), а запрос проверки
идёт с `trustedPeer`, — значит утверждение истинно при любом исходе. Первые два
утверждения тоже истинны при снятом страже: заголовка `Remote-User` в запросе
нет, слой удалил бы `Remote-Email` и пропустил дальше, узнавание всё равно
ответило бы `401` и записи бы не завело.
Мутационную сверку — снять страж и убедиться, что проверка зеленеет, — не
делал: `CLAUDE.md`, «Запреты», прямо запрещает мутационную сверку оракулов.
- Последствие: сценарий дельта-спеки «Пустая секция имитации ничего не трогает»
не проверен ничем, хотя в наборе проверок выглядит проверенным, — а именно к
этому сценарию спека привязывает своё «поведение, которого в бою нет»
(срезанный `Remote-Email` у запроса без `Remote-User`). Дубль предиката при
этом расходится в первой же правке, дающей непустой признак при пустой карте:
исход будет «старт предупреждает о подстановке, а слоя в цепочке нет» — и
предупреждение соврёт.
- Предложение: `AppChain` принимает вторым значением сам предикат
(`substituting bool`) вместо того, чтобы выводить его из длины карты; проверка
судит **дошедшее до узнавания** — что `Remote-Email`, посланный запросом,
доехал до обработчика нетронутым, — а не журнал.
- Найдено проходами: `code/техника` (C1), `architecture` (R3), `specs`
(замечание про два места).
- **Действие: инлайн.**
### 7. Два нормативных документа описывают изъятие мягче, чем оно есть: паспорт обосновывает его доводом, который в изъятии не действует, а принцип архитектуры запрещает ровно то, что сам приводит примером
- Файл: `docs/passport.md:65-75`; `docs/architecture.md:84-91`
- Severity: minor
- Confidence: high
- Оракул: дословное сопоставление внутри одного изменения.
(1) `docs/passport.md:73` — «подставленное имя проходит то же узнавание, что и
пришедшее, **а кого пускать, по-прежнему решает провайдер**». Дельта-спека в
том же изменении: «Подстановка — это настройка, которой сервис называет
пришедшего сам, **никого не спросив**» (`substitute.go:32-33` теми же словами).
Провайдера в изъятии нет вовсе. Там же паспорт пишет «*Изъятие одно, и оно про
прогон без контура*» — код таким не ограничен: подстановка работает везде, где
адрес пира доверенный, и `docs/security.md:97-102` это признаёт прямо
(«Боевая поломка машиной не исключена»). Два документа одного изменения
расходятся об одном изъятии.
(2) `docs/architecture.md:84-91` — заголовок нормы «Подставной собеседник
живёт **в коде или в оснастке**», тело — «только под ключом, названным своим
предметом, — **не под общим словом вроде «режим отладки»**», и следом
собственным примером норма называет случай, который живёт в боевом бинарнике и
стоит под `[server] debug`. Единственная операционная формулировка изъятия —
закрытый перечень следствий — живёт в спеке, и принцип на неё не ссылается.
- Последствие: `docs/passport.md` — единственный дом границы домена, и `CLAUDE.md`
велит читать его перед задачей. Записанная так граница перестаёт исключать то,
ради чего заведена: следующая задача (в бэклоге лежит `api-tokens`) сможет
сослаться на строку «кого пускать, решает провайдер» как на действующее
ограничение, которого нет. Норма архитектуры, обратно, читается как «шипящая
реализация нарушает наш собственный принцип» — а ключ `[server] debug`
владелец подтвердил дважды и переоткрывать его нельзя; значит расходится норма,
а не код. `docs/review.md` при этом уже объявил пробел закрытым этой нормой,
то есть на неё уже сослались.
- Предложение: в паспорте заменить довод на верный («сервис называет пришедшего
сам; границы домена это не двигает, потому что учётных записей он по-прежнему
не заводит и допуска не проверяет — заведение строки первым обращением
остаётся зеркалированием») и снять слова «оно про прогон без контура». В
`docs/architecture.md` свести принцип к связывающему правилу со ссылкой на
требование спеки (закрытый перечень следствий предохранителя), не повторяя
перечень второй копией.
- Найдено проходом: `architecture` (R1, R2).
- **Действие: инлайн** (две правки, оба файла — документация).
---
## Гипотезы без доказательства
Понижены: оракула на этом прогоне триаж не добыл — по бюджету (одна попытка на
находку ушла на `critical`/`major`) либо по запрету проекта.
### Имя копии в каталоге данных выводится из идентификатора записи одним инкрементом
- Файл: `internal/ident/ident.go:79-99`; `internal/service/transcribe.go:176-201`
- Было: minor / high (`adversary`, V2). **Осталось minor:** оракул у прохода
есть (`TestProbeStorageNameDerivesFromRecordID`, `TestProbeDerivationRate`
49 из 50 приёмов подряд), но триаж его не воспроизводил, а согласие одного
прохода с самим собой подтверждением не является.
- Последствие: инвариант `CLAUDE.md` «Имя файла на диске задаёт сервис, а в
журнал не идёт… строка журнала иначе стала бы бессрочным ключом к чужой
записи» соблюдён по букве и не по назначению: `record_id` и `file_ext`
присутствуют в журнале порознь и дают имя арифметикой. Доступа это не даёт —
карточка и файл сужены владельцем, — поэтому не выше minor.
- Предложение: случайное положительное приращение вместо единицы либо снять
посылку из инварианта. Уходит задачей.
### Управляющий знак в расширении имени даёт отправителю `500`, а владельцу — `ERROR`, неотличимый от аварии хранилища
- Файл: `internal/service/transcribe.go:171-188`;
`internal/adapter/repo/sqlite/file_repo.go:56-67`
- Было: minor / high (`adversary`, V3). **Осталось minor:** падающий тест у
прохода есть, триаж его не воспроизводил.
- Последствие: комментарий `transcribe.go:171-175` объявляет этот исход
недопустимым и ради него заведён `maxExtLen`; разрез сделан по длине и не
сделан по составу знаков. Побочно `newWorkFile` не снимает путь с
`*os.PathError`, и путь во временном каталоге уезжает в строку `ERROR`.
- Предложение: судить состав знаков расширения тем же приёмом, что и длину.
Уходит задачей.
### Текст отказа провайдера уезжает в `error_text` и в журнал целиком, а самый ожидаемый его вид несёт `sourceURI` — бакет и ключ объекта
- Файл: не локализован проходом точнее уровня «ветвь отказа распознавания»
- Было: minor / medium, **оракула нет и быть не может на этом прогоне**:
настоящий SpeechKit нужен, а `CLAUDE.md`, «Запреты», прогон на реальных ключах
запрещает («Yandex Cloud за деньги»). Строка живёт в
`docs/review.md`, «Недоступно проверке», первым списком.
- Последствие (условное): ключ объекта в `error_text` виден владельцу записи
карточкой; инвариант «Содержимое записи остаётся приватным» им прямо не
нарушен, но перечень того, что уходит в текст ошибки, ничем не ограничен.
- Предложение: задачей — ограничить текст внешнего отказа перечнем полей, а не
пересказывать его дословно.
### Требования «прокси обязан дописывать `X-Forwarded-For`» нигде не записано, в отличие от `Remote-*`
- Файл: `docs/security.md:103-113`
- Было: minor / medium (`adversary`). **Оракула нет:** правило живёт в
`files/caddyproxy/Caddyfile.template` репозитория `pet-project-server`, и
`docs/review.md`, «Недоступно проверке», относит поведение обратного прокси к
тому, чего не проверит ни один проход.
- Последствие (условное): `docs/security.md` описывает чтение цепочки
справа налево словами «правое приписал ближайший к нам прокси» — это
предположение о прокси, а не требование к нему. Прокси, который `X-Forwarded-For`
не дописывает вовсе, оставляет ключ бюджета ограничителя целиком в руках
спрашивающего.
- Предложение: задачей — дописать требование к контуру рядом с уже записанным
требованием про `Remote-*`.
---
## Promote candidates
- **Правило «`internal/config` не знает транспорта» механизируемо, но не
механизировано.** Норма записана этим же изменением
(`docs/conventions/config.md`, «Проверка, охватывающая две секции разом»), а
`grep -n "config" internal/archrules/arch_test.go` не даёт ни одного
совпадения: сканера у правила нет. Кандидат — правило `internal/archrules` по
нетестовым файлам пакета. Предложил проход `architecture`.
**Оговорка к механизации:** `internal/config/test_headers_test.go:1-8`
объявлен `package config` и импортирует `internal/controller/http` — то самое
ребро. В рабочем бинарнике его нет, но `go test ./internal/config` линкует всю
поверхность HTTP, а будущая надобность конфига в транспорте даст цикл импорта
на уровне проверок. Правило по нетестовым файлам этот файл пропустит; правило
по всем — уронит гейт до переноса файла во внешний `package config_test`.
- **`errors.Join` в проверках конфига.** `docs/conventions/errors.md`,
«Несколько ошибок», называет проверку конфига поимённо и требует «все проблемы
разом»; `ValidateTestHeaders` возвращается на первом несовпадении, записи
«*Расхождение:*» у пункта нет. Это претензия на правило, а не на этот код:
проверки остальных секций ведут себя так же. Предложил проход `code` (C5).
- **Сверка `toml.MetaData.Undecoded()` как норма конфига.** Вариант 2 развилки
находки 2. Если владелец выберет узкую правку, кандидат остаётся здесь:
непонятый ключ в любой секции сегодня проходит молча.
---
## Границы покрытия
### План: темы, глубины, дома
Воспроизведён таблицей в сводке выше. Дома: `openspec/specs/access/spec.md` +
дельта (`requirements`, разбор); `CLAUDE.md`, «Гейт» (`autotests`);
`docs/conventions/` целиком (`conventions`, разбор); `docs/architecture.md` +
источник `docs/passport.md` (`architecture`, доказательство); `docs/security.md`,
«Периметр» (`security`, доказательство); `docs/architecture.md`, «Эксплуатация» +
источник `docs/database.md` (`operations`, доказательство). **Тема без дома
одна** — «тема проекта»: своих тем сверх ядра проект не завёл, и это состояние
плана, а не пропуск.
### Какие проходы запускались
Метка `large`, режим «по графу». Запускались шесть: `autotests`, `specs`, `code`,
`architecture`, `adversary`, `ops`. Триаж — седьмой.
### Какие не запускались и почему
- `basics`**по метке**: на `large` он не запускается, потому что все темы
ядра разобраны именными проходами, а своих тем у проекта нет. Прямое следствие:
второго независимого голоса о заниженности метки нет — сигнал пришёл только от
`review-code`.
- Проход независимой реализации — снят из конвейера по стоимости.
- Проход про идиоматичность — упразднён.
### Чего каждый запущенный проход не мог проверить в принципе
- `autotests` — судит гейт и покрытие, но не судит, **что** проверяют зелёные
проверки: находка 6 (проверка, которая не может упасть) из его charter'а не
видна, её принёс `code`.
- `specs` — судит соответствие кода дельте и полноту дельты; не судит, работает
ли требование в настоящем прогоне (потому S4 у него остался рассуждением, а
оракул добыл триаж).
- `code` — судит записанные конвенции и технику; периметра и эксплуатации не
касается.
- `architecture` — судит направления зависимостей и нормы домов; карту графа
собирал **вручную**, подготовленной команды у проекта нет.
- `adversary` — строит пути; поведения настоящих внешних собеседников не
проверяет вовсе.
- `ops` — судит эксплуатацию по замерам своего прогона; профиля настоящей
нагрузки у него нет (проект работает на единицах записей в день).
- **Триаж (этот проход)** — ничего нового не находит по определению: работает с
чужими выводами, кода в поисках дефектов не читает. Пропуск любого прохода —
пропуск триажа тоже, и единственное средство против него — поимённая сверка
плана выше.
### Что осталось целиком на человеке
**Не проверит ни один проход** (`docs/review.md`, «Недоступно проверке», первый
список — воспроизводится отдельно от второго намеренно):
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
сервиса — **от прокси зависит весь барьер**, он обязан заголовки `Remote-*`
перезаписывать, а не пропускать пришедшие; правило живёт в
`pet-project-server`. **Прямо относится к находке 5:** сценарий
дописывающего прокси проверить отсюда нечем, а асимметричную реакцию сервиса
на него — можно, и она проверена;
- `security`: поведение браузера с куками (класс пуст с 2026-08-22, строка стоит
как маркер).
**Перестали проверять сознательно** (второй список, тот же раздел):
- `autotests`: разбор вывода настоящего `ffprobe` — проверки получают
длительность от подставного источника; решение и цена в
`adr/ADR-2026-08-11-stub-adapters-in-tests.md`;
- работа сервиса с настоящими внешними собеседниками. Сам сервис поднять можно и
этот прогон его поднимал; **остаток** — за настоящие SpeechKit и Object
Storage живой прогон не отвечает, ключи выдуманные, распознавание подменяется
в коде. Вход живой прогон с 2026-08-22 проверяет целиком, и раздел уже
переписан этой задачей: подставного прокси нет, заголовок ставит сам сервис.
**Общее, вне обоих списков:** история инцидентов, поведение под реальным
потоком, поведение внешних систем в их версиях, завязка потребителей на текущее
поведение и вопрос «а нужна ли эта функциональность вообще» — ничем из
перечисленного конвейер не занимался.
### Четыре строки, которых не принесёт ни один проход
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
его не открывает. Расхождение изменения с записанным решением ловит скилл
`av-dev:doc-healthcheck`, а не ревью. В этом изменении есть чему разойтись:
`ADR-2026-08-22-login-by-trusted-header.md` описывает вход, к которому
заведено изъятие, и сверен с ним никем не был.
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
процессный. Всякое число в отчёте снято проходом на этом прогоне: бенчмарк
`BenchmarkSubstituteIdentityHeaders` (2972 ns/op, 5718 B/op, 17 allocs/op) —
замер прохода `ops`, покрытие (`test_headers.go` 100 %,
`SubstituteIdentityHeaders` 94,4 %, `SubstitutedHeaderNames` 0,0 %) — вывод
`go tool cover -func` прохода `autotests`, остальные числа — прогоны триажа с
приложенными командами.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
проходом.** Различение «идиоматично против распространено» не спрашивает
никто с тех пор, как упразднён проход про идиоматичность.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру; «не
знаю, чего не знаю» на этом прогоне не доставал никто.
Метка — `large`, поэтому пятой строки (сужение `security`/`operations`/
`architecture` до записанных инвариантов) здесь нет: все три темы разобраны
именными проходами на глубине «доказательство».
### Каких документов проекта не хватило
- **`docs/review.md`, «Типовые ложноположительные» — есть и непуст**, прочитан
целиком (5 записей, две из них отменённые намеренно). Ни одна находка этого
прогона под них не подпадает; отсев шёл и по проектному входу, и по общим
критериям.
- **Раздела инвариантов `CLAUDE.md` хватило.** На него опираются находки 4
(«Секрет не покидает конфиг», **critical**, необратимо) и гипотеза про имя
копии («Имя файла на диске задаёт сервис, а в журнал не идёт», **critical**).
Ранжирование по обратимости взято оттуда же, а не выведено из кода.
- **Не хватило: у `[server] debug` нет дома, называющего исчерпывающий перечень
его следствий в одном месте.** Перечень объявлен трижды — комментарием
`internal/config/config.go:65-71`, комментарием `config.example.toml:7-13` и
требованием дельта-спеки, — и три копии уже расходятся: комментарий конфига
называет пять исключённых следствий, образец — четыре (в нём нет «проверок
старта»). Строка о том, где перечень канонический, отсутствует, и триаж
выбирал спеку по общему правилу «спека нормативна», а не по записанному
указателю.
- **Не хватило: `docs/conventions/logging.md` не называет, каким уровнем
пользоваться, когда отладочный недостижим.** Уровень журнала у сервиса зашит,
ключа у него нет, и конвенция про это молчит — поэтому находка 1 подана
развилкой, а не инлайном: своего основания выбрать вариант у триажа нет.
- **Оракул из журнала дефектов не применялся:** ни одна запись журнала
`docs/review.md` не описывает класс, совпадающий с находками этого прогона.
Ближайшая — «ключ бюджета ограничителя выбирал тот, кого ограничивают»
(2026-08-23) — соседствует с гипотезой про `X-Forwarded-For`, но предмет у неё
другой.
### Сработавшие потолки
- **По проходам: ни один не сообщил свой потолок.** Ни `autotests` (1 находка),
ни `specs` (4), ни `code` (5), ни `architecture` (3 + 2 замечания), ни
`adversary` (3 + 3 свойства), ни `ops` (1) не сказали, каков был их предел и
что осталось за срезом. Это находка о прогоне, вынесенная в сводку отдельной
строкой; закрыть её отсюда нечем.
- **Потолок триажа сработал.** На входе 21 находка, в первые две секции влезло
7. **Не влезло и здесь названо поимённо:**
- `A1`/`S2``SubstitutedHeaderNames` не вызвана ни одним тестом
(`go tool cover -func` → 0,0 %). Триаж добыл ей однократный оракул: прогон
настоящего бинарника печатает `headers=[Remote-User]` — имена, не значения,
— то есть **дефекта сегодня нет**, есть отсутствие защиты от регрессии
против MUST NOT дельта-спеки («строка старта MUST не нести значений»).
Понижено до minor, инлайн-правка на один тест, если у оркестратора останется
бюджет;
- `C3` — умолчания `Debug` и `TestHeaders` заданы нулём типа, а не в
`defaultConfig()`. Нарушена записанная конвенция
`docs/conventions/config.md:181-191`: «Умолчания задаются в `defaultConfig()`
Новое поле требует правки обоих мест», и там же форма обязательного поля —
«умолчания нет ни в `defaultConfig()`, ни по нулевому значению типа». Форма
записи необязательного поля стала неотличима от формы обязательного. `nit`,
инлайн;
- `C4``internal/config/test_headers_test.go` объявлен `package config` и
импортирует `internal/controller/http`, ребро запрещено той же правкой в
`docs/conventions/config.md`. `nit`; отражён оговоркой в Promote candidates,
потому что чинить его в одиночку смысла меньше, чем вместе со сканером;
- `C5``ValidateTestHeaders` возвращается на первом несовпадении вместо
`errors.Join`. Уехал в Promote candidates: правило шире этого кода;
- `C2` — комментарий `Dockerfile:49-50` объясняет исключение `cmd/devtools`
словами «оснастка разработчика (подставной прокси)», а подкоманды больше нет;
приёмочный шаг `tasks.md:88` искал следы по строке `devtools proxy` и эту не
поймал. Плюс `internal/controller/http/mounts.go:54-56` перечисляет три звена
цепочки, тогда как `AppChain` одевает четыре. `nit`, инлайн, две строки;
- **расхождение записи имени ключа**: `[auth] test_headers` в дельта-спеке,
`docs/security.md`, `docs/architecture.md` и `docs/review.md` против
`[auth.test_headers]` в `config.example.toml`, `README.md`, `CLAUDE.md`,
`docs/conventions/config.md` и в текстах отказов старта самого кода
(`test_headers.go:16`). Человек, ищущий в конфиге то, что назвал отказ
старта, найдёт вторую форму; первая в файле не встречается вовсе. **Не
находка этого прохода:** `CLAUDE.md`, «Гейт», относит согласованность
документов между собой и с кодом к скиллу `av-dev:doc-healthcheck`. Названо
здесь, чтобы не потерялось;
- **рецепт локального входа записан четырьмя копиями** — `config.example.toml`
(канон), `README.md:48-61`, `CLAUDE.md:276-288`,
`docs/conventions/config.md:66-75`. Замечание `architecture` «дешевле
переделать до мерджа»; по той же причине уходит в `doc-healthcheck`;
- границы прохода `specs`, перенесённые без изменений: значения
`Remote-Name`/`Remote-Email` в секции имитации на старте не судятся ничем
(годность проверяется только у логина); `strings.TrimSpace` над именем ключа
— поведение сверх спеки, последствий у него не найдено.
### Урожай враждебного прохода
Пути обхода **самой подстановки** — предмета этого изменения — не построено:
шесть отрицательных проб (два значения `Remote-User` при включённой подстановке
`401`; чужие `remote-email`/`remote-name` в нижнем регистре на живом сервере →
слой владеет тройкой целиком; сверка адреса идёт той же функцией, что у
узнавания; неразобранный `RemoteAddr` → отказ подстановки; канонизация ключей
против регистра, NBSP, ZWSP → старт падает; у `[server] debug` второго
потребителя нет). Состояния «подстановка работает, а владелец не знает» не
найдено. **Это положительный исход по теме, а не отсутствие работы.**
Построенное легло на соседнюю поверхность. Судьба каждой:
| Находка | Чинится этим мерджем? | Куда |
|---|---|---|
| V1 — почта берётся первым значением, сквоттинг молчит | **развилка**, вариант 1 берёт в мердж | находка 5 |
| `.env` не игнорируется git | **да**, одной строкой | находка 4 (инлайн) |
| `godotenv.Load()` мёртв (ничто не читает переменные окружения), а `WARN` на каждом старте приглашает завести `.env` | нет | **задачей**: снять зависимость `github.com/joho/godotenv` из `go.mod`, четыре строки из `cmd/transcriber/main.go:114-117` и строку `.env` из `.dockerignore` — либо, если переменные окружения нужны, завести им читателя. Оракул для задачи: `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ \| grep -v _test` → пусто; прогон бинарника печатает `level=WARN msg="Warning: .env file not found, using system environment variables"` |
| V2 — имя копии выводится из `record_id` инкрементом | нет | **задачей**, гипотеза выше; оракулы прохода названы поимённо |
| V3 — управляющий знак в расширении → `500` и `ERROR` | нет | **задачей**, гипотеза выше |
| Текст отказа провайдера с `sourceURI` в `error_text` | нет | **задачей**, оракула нет и на этом прогоне быть не может (запрет на реальный SpeechKit) |
| Требование к прокси про `X-Forwarded-For` не записано | нет | **задачей** в `docs/security.md`, рядом с уже записанным требованием про `Remote-*` |
Заведение задач — не работа триажа; формулировки выше отданы с оракулами, чтобы
ни одна не потерялась при переносе.
### Оракулы, добытые триажем
Все тесты писались во временные файлы пакетов и **удалены** после прогона;
рабочее дерево на момент сдачи отчёта содержит ровно те изменения, что были на
входе (`git status --short` сверен). Прогоны бинарника шли в каталог скретчпада,
не в `data/`; поднятый сервис остановлен за собой (`pkill`), порт освобождён —
правило `docs/review.md:16-23`.
1. `go test ./internal/config/ -run TestTriage -v` — три случая разбора TOML
(дубль по регистру, опечатка в имени секции, опечатка в имени ключа).
2. `go test ./internal/controller/http/ -run TestTriage -v` — сквозной сквоттинг
почты на настоящей поверхности `AppChain` и асимметрия правила многозначности.
3. `go build -o <скретчпад>/transcriber ./cmd/transcriber` + два прогона
настоящего бинарника на порту 18099 с каталогами данных в скретчпаде: конфиг
с опечаткой в имени секции и конфиг правильный.
4. `git check-ignore -v .env`; `grep -rn "\.Debug(" --include=*.go internal/ cmd/`;
`grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/`;
`grep -n "config" internal/archrules/arch_test.go`.
Гейт триаж не перезапускал: его исход взят у прохода `autotests` как замер,
снятый на этом прогоне.
@@ -0,0 +1,508 @@
## ADDED Requirements
### Requirement: Отладочный запуск называет пришедшего настройками
Сервис SHALL подставлять запросу заголовки входа значениями из секции
`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST
делать это так, чтобы узнавание не отличало подставленный заголовок от
поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана
быть той же, что работает в бою: отладке подлежит боевой путь, а не его
отладочный двойник.
Подстановка MUST происходить только при **отсутствии** заголовка `Remote-User` в
запросе. Заголовок, пришедший в любом числе значений, MUST оставаться нетронутым:
узнавание отвергает запрос с более чем одним значением, и подстановка, дописавшая
второе, превратила бы законный отказ в проход.
Подстановка MUST происходить только тогда, когда адрес соединения попадает в
перечень `[auth] trusted_proxies`, и адрес этот MUST судиться **той же
функцией**, какой судит его узнавание: и разбор адреса пира, и сверка с
перечнем берутся из одного дома, а не пишутся вторым списком. Второй перечень
разошёлся бы с первым молча — так же, как разошлись бы два списка имён
заголовков. Второго барьера у подстановки нет и не заводится: круг тех, кто
вправе назвать пришедшего, уже очерчен этим перечнем.
Подстановка MUST действовать только при **непустой** секции имитации. Пустая
секция значит «подставлять нечего»: цепочка слоёв при ней та же, что при
выключенном предохранителе, и ни одного заголовка запроса слой не трогает.
Иначе включённый предохранитель при пустой секции срезал бы `Remote-Email` у
запроса, пришедшего без `Remote-User`, — поведение, которого в бою нет.
Подставляя, сервис MUST распоряжаться всей тройкой заголовков `Remote-*`
целиком: названные секцией ставятся её значением, не названные удаляются.
Запрос без `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
источников — логин свой, почту чужую.
Подстановка MUST действовать только под корнем приложения — там же, где действует
узнавание. Проба здоровья, метрики и раздача приложения её не видят.
#### Scenario: Запрос без заголовка входа получает имя из настроек
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса без единого заголовка `Remote-*`
- **THEN** сервис узнаёт пришедшего под именем из секции
- **AND** учётная запись заводится, если её не было
#### Scenario: Пришедший заголовок не подменяется
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса с заголовком `Remote-User`
- **THEN** узнавание получает значение из запроса, а не из настроек
#### Scenario: Два значения заголовка остаются отказом
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с двумя значениями заголовка `Remote-User`
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Недоверенный адрес имени не получает
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос без заголовка приходит с адреса вне перечня доверенных
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Чужой заголовок почты не смешивается со своим логином
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и не
называет `Remote-Email`
- **WHEN** запрос приходит без `Remote-User`, но с заголовком `Remote-Email`
- **THEN** узнавание получает логин из настроек и не получает адреса почты вовсе
#### Scenario: Пустая секция имитации заголовков не трогает
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без `Remote-User`, но с
заголовком `Remote-Email`
- **THEN** слой заголовков не трогает, и `Remote-Email` доходит до узнавания
нетронутым
#### Scenario: Выключенный предохранитель имён не раздаёт
- **GIVEN** предохранитель выключен, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без заголовка `Remote-User`
- **THEN** запрос остаётся неузнанным
#### Scenario: Наблюдение подстановки не видит
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ тот же, что и при выключенном предохранителе
### Requirement: Настройка, открывающая вход всем, роняет старт
Сервис SHALL отказываться подниматься на настройках, при которых отладочный вход
становится открытым входом, и MUST называть в отказе имя ключа. Проверка идёт на
старте, до приёма трафика: настройка, отданная на честность выкладки, проверяется
только тем, что чужой архив уже уехал не тому.
Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server]
debug` MUST читаться как выключенный предохранитель, а отсутствие секции
`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый
ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST
кончаться отказом старта, а не прочтением «включено».
Секция имитации MUST считаться **непустой**, как только в ней назван хотя бы
один ключ, каким бы ни было его значение. `Remote-User = ""` — заполненная
секция, а не пустая: человек, написавший ключ, имитацию завёл, и пустое значение
у него — вторая поломка, а не отсутствие первой.
Отказом MUST быть каждый из четырёх случаев.
Первый — секция имитации заполнена при выключенном предохранителе. Состояние это
не читается никак: либо человек забыл включить предохранитель и будет искать
поломку везде, кроме одного ключа, либо забыл убрать имитацию из боевого файла — и
тогда до открытого архива остаётся одно слово. Молчаливое игнорирование и
предупреждение в журнале оба оставляют вторую поломку жить.
Второй — секция имитации называет ключ, не совпадающий ни с одним заголовком
входа, который сервис читает. Перечень принимаемых имён MUST порождаться теми же
именами заголовков, которыми пользуется узнавание, а не перечисляться вторым
списком; сравнение MUST идти по каноническому виду имени, потому что в HTTP имя
нечувствительно к регистру, а ключ конфига чувствителен. Опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа.
Третий — секция имитации непуста, а годного `Remote-User` в ней нет. Ключ
логина MUST быть назван: секция, называющая один `Remote-Email`, поднимает
сервис, который подставит почту, удалит логин и не узнает никого. Значение
логина MUST проходить **тот же приём**, каким узнавание судит пришедшее
значение (`internal/entity.AcceptProviderLogin`): пустое, из одних пробельных
знаков, сверх предела длины и с управляющими знаками — отказ старта. Иначе
сервис поднимается, ставит заголовок, получает отказ приёма и отвечает
неузнанным на всё, оставляя за собой одну отладочную строку. Оба состояния — то
самое «не читается никак», ради которого заведён первый случай.
Четвёртый — два ключа секции дают одно каноническое имя заголовка. `Remote-User`
и `remote-user` для TOML — два ключа, для HTTP — одно имя: сервис подставил бы
одно значение, а второе потерял бы молча, и человек искал бы поломку в значении,
которого сервис не читал вовсе. Отказ MUST называть секцию и причину, а значений
MUST не называть.
Включённый предохранитель при пустой секции имитации отказом MUST не быть: сам по
себе он ничего не включает.
#### Scenario: Имитация без предохранителя не поднимается
- **GIVEN** секция имитации заполнена, предохранитель выключен
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Неизвестное имя заголовка не поднимается
- **GIVEN** предохранитель включён, секция имитации называет ключ, которого нет
среди читаемых заголовков входа
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет принимаемые имена
#### Scenario: Имитация без ключа логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет только
`Remote-Email`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя недостающего ключа логина
#### Scenario: Два ключа одного заголовка не поднимаются
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и
`remote-user`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет секцию, в которой два ключа дали одно имя
заголовка
#### Scenario: Негодное значение логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
значением в 300 знаков
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа, а не его значение
#### Scenario: Пустое значение логина считается заполненной имитацией
- **GIVEN** предохранитель выключен, секция имитации называет `Remote-User = ""`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Конфиг без ключа предохранителя ведёт себя как с выключенным
- **GIVEN** конфиг не называет ни `[server] debug`, ни секции имитации
- **WHEN** сервис запускают
- **THEN** сервис поднимается, подстановки не заводит и никого сам не называет
#### Scenario: Предохранитель без имитации поднимается
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис запускают
- **THEN** сервис поднимается
### Requirement: Отладочный запуск виден в журнале
Сервис SHALL сообщать владельцу о включённой подстановке заголовков строкой
журнала при старте и MUST не печатать при этом ни одного подставляемого значения.
Сервис, называющий пришедшего сам, — это ровно то «может стать проблемой», ради
которого заведён предупреждающий уровень: строка старта идёт на `WARN`.
Строка старта MUST нести имена подставляемых заголовков и MUST не нести их
значений. Логин — ключ к чужому архиву, и запрет на печать значения, дающего
доступ, действует на подставленное значение наравне с пришедшим.
Запрос, которому заголовки подставлены, SHALL писаться на уровне `INFO`:
этой строкой «узнан подстановкой» отличается от «узнан прокси». Боевой журнал
она не топит по построению — пишется только там, где подстановка работает, а в
бою предохранитель выключен умолчанием; на отладочном уровне её не увидел бы
никто, потому что настройки под уровень журнала у сервиса нет и он зашит `INFO`.
Строка MUST нести адрес пира и не нести подставленных значений.
Запрос, которому подставить нельзя из-за недоверенного адреса, MUST оставлять
**свою** строку предупреждающим уровнем. Узнавание на этом месте предупреждения
не пишет: подстановка работает при отсутствии `Remote-User`, а отсутствие
заголовка узнавание считает случаем штатным и пишет о нём отладочную строку;
предупреждение о недоверенном адресе оно бережёт для заголовка, который
**пришёл**. Без своей строки самый частый локальный отказ — браузер пришёл
с `::1`, а перечень называет `127.0.0.1` — не отличим от поломки узнавания:
человек видит `401` на всём приложении, заполненную секцию имитации и
включённый предохранитель. Строка MUST нести адрес пира и MUST не нести
подставляемых значений.
#### Scenario: Старт с подстановкой предупреждает владельца
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** сервис поднимается
- **THEN** журнал несёт предупреждение с именами подставляемых заголовков
#### Scenario: Подставленного значения нет в журнале
- **GIVEN** предохранитель включён, секция имитации называет логин и адрес почты
- **WHEN** сервис поднимается и принимает запрос без заголовка
- **THEN** ни логин, ни адрес почты не встречаются ни в одной журнальной записи
#### Scenario: Отказ подстановки на недоверенном адресе виден в журнале
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос без заголовка `Remote-User` приходит с адреса вне перечня
доверенных
- **THEN** журнал несёт предупреждение с адресом пира
- **AND** подставляемых значений в строке нет
#### Scenario: Подстановка на запросе видна при боевом уровне журнала
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос с доверенного адреса получает подставленные заголовки
- **THEN** строка об этом имеет уровень `INFO` и видна журналу, настроенному
по-боевому
### Requirement: Предохранитель отладки включает только подстановку заголовков
Предохранитель `[server] debug` SHALL менять одно поведение сервиса — подстановку
заголовков входа — и MUST не менять никакого другого. Перечень следствий закрыт, и
новое следствие вешается на этот ключ только отдельным требованием спеки: ключ,
названный общим словом, иначе обрастает всем подряд, и выключить его перестаёт
означать «сервис ведёт себя как в бою».
Сам по себе включённый предохранитель не включает и подстановки: она MUST
действовать только при непустой секции имитации, и при пустой цепочка слоёв MUST
быть та же, что при выключенном предохранителе, — требование «Отладочный запуск
называет пришедшего настройками».
Включённый предохранитель MUST не менять уровень журнала, не добавлять в ответы
тексты внутренних отказов и следы стека, не выводить тела запросов и ответов
внешних сервисов, не снимать и не ослаблять ограничитель частоты, не подменять
распознаватель, не ослаблять ни одной проверки старта и не открывать неузнанному
ни одного адреса.
#### Scenario: Включённый предохранитель без имитации ничего не меняет
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис принимает запросы
- **THEN** он ведёт себя ровно так же, как с выключенным предохранителем
#### Scenario: Ограничитель частоты работает и в отладочном запуске
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запросы идут чаще дозволенного
- **THEN** ограничитель частоты режет их так же, как при выключенном
предохранителе
## MODIFIED Requirements
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
сервиса — настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и правило, настроенное слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
жизни этого значения. Теперь отзыв действует со следующего запроса.
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` доверенным источником становятся настройки сервиса: сервис
называет пришедшего сам, никого не спросив. Это единственное место, где имя
берётся не от провайдера. Требования к нему стоят выше отдельными строками, и
снимать их поодиночке нельзя: барьер держится всеми разом.
Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию
выключен, а заполненная имитация без него роняет старт. Боевой перечень
доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с
включённым предохранителем и заполненной имитацией, назовёт своим именем всякого,
чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным
путём.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
прокси
- **THEN** доступ открывается, а учётная запись заводится, если её не было
- **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа
и адреса почты
#### Scenario: Отзыв у провайдера действует со следующего запроса
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
#### Scenario: Без отладочного запуска имя приходит только от провайдера
- **GIVEN** предохранитель отладки выключен
- **WHEN** запрос приходит с доверенного адреса без заголовка
- **THEN** сервис никого не узнаёт и своего имени пришедшему не назначает
### Requirement: Пришедшего называет доверенный источник
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` заголовок входа ставит не прокси, а сам сервис значением из
секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает. Условия, при которых источник этот законен,
и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск
называет пришедшего настройками», «Настройка, открывающая вход всем, роняет
старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает
только подстановку заголовков». Держится изъятие умолчанием предохранителя
«выключено» и отказом старта при заполненной имитации без него; боевой перечень
доверенных адресов включению предохранителя не мешает.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
барьер вовсе.
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
требованием учётной записи на адресах приложения.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а
на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало
бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы
учётные записи, которые потом не убираются ничем.
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
Область — корень приложения; она MUST выводиться из объявленного адресного
пространства сервиса, а не перечисляться вторым списком. Прежде область была
шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни
адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность
хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в
коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой
нет, и вычитать больше нечего.
Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание
MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения.
Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос
с новым именем — записи в неё.
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
без этой нормы все неназванные собрались бы в одну учётную запись с общим
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
бы вход анониму. Значение сверх объявленного предела длины и значение с
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
считает колонка.
Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
настройке журнала: обе строки означают поломку контура, а поломка, записанная
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
которого никто не может войти, не отличит своей поломки (перечень доверенных
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
нести значения заголовка.
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла.
Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию,
что и всё прочее, и отзыв доступа доходит до него сразу.
Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не
однажды выданный срок.
Собственных токенов сервис не принимает: значения, предъявленного запросом и
дающего доступ помимо заголовка, у него не существует. Прежде такое значение
било заголовок — им пользовался владелец панели; панели нет, и правило приоритета
осталось бы правилом без предмета.
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
адресом его почты.
#### Scenario: Заголовок с доверенного адреса узнаёт человека
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
- **THEN** запрос идёт от имени учётной записи с этим значением
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
- **GIVEN** адреса источника в перечне доверенных нет
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
- **THEN** ответ имеет код `401`
- **AND** учётной записи с этим значением не появляется
#### Scenario: Предъявленного значения сервис не признаёт
- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа
в заголовке или в параметре
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается названный заголовком
#### Scenario: Пустой заголовок не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Два значения одного заголовка не узнают никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Значение сверх предела длины не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Область узнавания — корень приложения
- **GIVEN** сервис поднялся
- **WHEN** смотрят, на каких адресах срабатывает узнавание
- **THEN** это адреса под корнем приложения, и второго списка адресов нет
#### Scenario: Проба здоровья учётной записи не заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
- **THEN** учётной записи не появляется
#### Scenario: Недоверенный источник виден в журнале
- **WHEN** запрос с заголовком приходит с недоверенного адреса
- **THEN** журнал несёт строку об этом исходе с адресом пира
- **AND** значения заголовка в ней нет
#### Scenario: Сервис не ставит браузеру куки
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения проходит с заголовком
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
@@ -0,0 +1,263 @@
## 0. Чекпоинт человека
Чекпоинт пройден: развилок не осталось, и решения владельца записаны здесь и в
`design.md`.
- [x] 0.1 Имена ключей конфига подтверждены дословно — `[server] debug` и
`[auth.test_headers]` (необратимое: имя ключа конфига)
- [x] 0.2 Форма имён в секции имитации подтверждена: ключ — имя заголовка, набор
принимаемых имён порождается константами транспорта, неизвестный ключ
роняет старт (`design.md`, решение 6)
- [x] 0.3 Адресного предохранителя не делаем — «полагаемся только на параметр
debug». Требование петлевого перечня доверенных адресов при включённом
предохранителе снято вместе с предикатом «петлевая запись»
(`design.md`, решение 4)
- [x] 0.4 Подкоманда `cmd/devtools proxy` убирается целиком (`design.md`,
решение 10)
- [x] 0.5 Критерии приёмки подтверждены владельцем; пункты 2 и 7 переписаны под
решение 0.3
## 1. Настройки
- [x] 1.1 Завести в `ServerConfig` ключ предохранителя с умолчанием «выключено» в
`defaultConfig()`; отсутствие ключа в конфиге читается как «выключено», а
негодное значение роняет старт, а не читается как «включено»
- [x] 1.2 Завести в `AuthConfig` подсекцию имитации заголовков в форме, выбранной
шагом 0.2; умолчание — пустая секция. Непустой секция считается при наличии
хотя бы одного ключа, каким бы ни было его значение: `Remote-User = ""`
заполненная секция
- [x] 1.3 Вывести перечень принимаемых имён заголовков из констант транспорта
`internal/controller/http/identity.go`, а не вторым списком; сравнение по
каноническому виду имени
- [x] 1.4 Завести проверку старта, охватывающую обе секции, методом на корневой
`Config` (`design.md`, решение 8), и звать её из `cmd/transcriber` рядом с
имеющимися проверками
- [x] 1.5 Отказ старта при заполненной имитации и выключенном предохранителе;
текст называет имя ключа предохранителя
- [x] 1.6 Отказ старта при неизвестном имени заголовка в секции; текст называет
принимаемые имена
- [x] 1.7 Отказ старта при непустой секции без годного `Remote-User`: ключ не
назван либо его значение не проходит `internal/entity.AcceptProviderLogin`
(пустое, пробельное, сверх предела длины, с управляющими знаками); текст
называет имя ключа и не называет значения
- [x] 1.8 Включённый предохранитель при пустой секции старт не роняет. Перечень
доверенных адресов старту при этом не судья: боевая запись перечня
включению предохранителя не мешает (решение 0.3)
## 2. Подстановка на входе
- [x] 2.1 Завести слой транспорта, подставляющий заголовки, в
`internal/controller/http`
- [x] 2.2 Поставить слой в цепочку корня приложения между ограничителем частоты и
узнаванием; при выключенном предохранителе и при пустой секции имитации
слой в цепочку не встаёт вовсе — цепочка та же, что сегодня
- [x] 2.3 Подставлять только при отсутствии `Remote-User`; запрос с любым числом
значений проходит нетронутым
- [x] 2.4 Подставлять только при попадании адреса пира в перечень доверенных, и
судить адрес **той же функцией**, какой судит узнавание (`peerAddress` и
сверка с перечнем из `internal/controller/http/identity.go`), а не вторым
списком
- [x] 2.5 Распоряжаться всей тройкой `Remote-*`: названные секцией ставить,
не названные удалять
- [x] 2.6 Проверить, что `TrustedHeaderIdentity` не изменён: отлаживается боевая
ветка узнавания
## 3. Журнал
- [x] 3.1 Предупреждение при старте с включённой подстановкой: имена заголовков,
поле `capability`, без значений
- [x] 3.2 Строка уровня `INFO` на запросе с подставленными заголовками: адрес
пира, `capability`, `transport`, без значений. Уровень боевой, и боевой
журнал строка не топит: пишется она только там, где подстановка работает,
а в бою предохранитель выключен умолчанием
- [x] 3.3 Предупреждение на запросе, которому подставить нельзя из-за
недоверенного адреса: адрес пира, `capability`, `transport`, без
подставляемых значений. Узнавание на этом месте молчит — заголовка нет, и
оно пишет отладочную строку
## 4. Оснастка
- [x] 4.1 Удалить подкоманду `proxy` из `cmd/devtools`: ветку `case "proxy"` в
`main()`, функцию `runProxy` и её флаги `-listen`, `-target`, `-user`,
`-name`, `-email`; неиспользуемые импорты уходят вместе с ней
- [x] 4.2 Править шапку пакета `cmd/devtools/main.go`: она говорит «Подкоманд
две: `proxy` — подставной обратный прокси, `resume` — …» и объясняет, зачем
прокси нужен. Остаётся одна подкоманда. Абзац про имена заголовков
константами вместе с прокси теряет предмет — оснастка их больше не читает
- [x] 4.3 Править `usage()` в `cmd/devtools/main.go`: убрать строку
`proxy подставной обратный прокси…`
- [x] 4.4 Убрать упоминания подкоманды из документов. Адреса — все, что нашёл
`grep -rn "devtools proxy"`, кроме каталога этого change и архива
`openspec/changes/archive/`:
- `README.md`, блок локального запуска («Локально прокси нет…» и команда
`go run ./cmd/devtools proxy`) — содержимое пишет шаг 6.5;
- `CLAUDE.md`, раздел «Команды», строка `go run ./cmd/devtools proxy`, и
раздел «Запреты», абзац «Локальный запуск не ходит наружу» — содержимое
пишет шаг 6.4;
- `config.example.toml`, блок «Локальный вход без Authelia» с двумя
вызовами подкоманды — содержимое пишут шаги 6.1 и 6.2;
- `docs/conventions/config.md`, абзац про закомментированный петлевой
адрес «под подставной прокси `cmd/devtools proxy`» — содержимое пишет
шаг 6.3;
- `docs/architecture.md`, строка таблицы «Оснастка владельца |
`cmd/devtools`»: «Подставной прокси для местного запуска и возврат
остановленной записи в работу» — содержимое пишет шаг 6.7;
- `docs/security.md`, перечень вне периметра, пункт «Машина разработчика и
то, что он на ней поднимает»: «её подкоманда `proxy` встаёт на место
контура» — содержимое пишет шаг 6.6;
- `docs/review.md`, запись о единственном доме подставных внешних
собеседников: «на их месте `cmd/devtools proxy`». Документ процессный, и
запись историческая: правится строкой о том, что прокси убран задачей
`config-test-headers-login`, а не переписывается
## 5. Тесты
- [x] 5.1 Тесты проверки старта по требованию «Настройка, открывающая вход всем,
роняет старт»: три отказа (имитация без предохранителя; неизвестное имя
заголовка; непустая секция без годного `Remote-User` — не названного и в
300 знаков) и законные случаи (предохранитель без имитации; конфиг, не
называющий ни одного нового ключа)
- [x] 5.2 Тесты слоя подстановки по сценариям требования «Отладочный запуск
называет пришедшего настройками», включая два значения `Remote-User`,
недоверенный адрес и чужой `Remote-Email`
- [x] 5.3 Тест: подставленное значение не встречается ни в одной журнальной
записи
- [x] 5.4 Тест: `GET /health` при включённом предохранителе отвечает так же, как
при выключенном
- [x] 5.5 Тест: ограничитель частоты работает при включённом предохранителе
- [x] 5.6 Тест: при включённом предохранителе и пустой секции запрос без
`Remote-User`, но с `Remote-Email` доходит до узнавания нетронутым
- [x] 5.7 Тест: запрос без заголовка с недоверенного адреса при включённом
предохранителе оставляет предупреждение с адресом пира
- [x] 5.8 Тест: конфиг без новых ключей даёт ту же цепочку слоёв и те же ответы,
что до изменения
## 6. Документы
- [x] 6.1 `config.example.toml`: обе новые настройки с комментарием — зачем,
допустимые значения и цена включения. Форма названа прямо: боевой перечень
доверенных адресов остаётся рабочим значением, `[server] debug` стоит рабочей строкой
`false`, секция `[auth.test_headers]` целиком закомментирована, а петлевые
адреса в перечне стоят комментарием **парой**`127.0.0.1` и `::1`:
браузер разрешает `localhost` в IPv6 не реже, чем в IPv4, и перечень без
`::1` даёт неузнанный запрос без единой понятной строки
- [x] 6.2 Записать рецепт локального входа одним связным блоком — списком правок
сверху вниз, а не тремя комментариями по месту: (1) добавить в перечень
доверенных адресов пару петлевых, (2) поставить `debug = true`,
(3) раскомментировать секцию имитации и назвать в ней `Remote-User`. Дом
блока — `config.example.toml`, и та же последовательность повторена ссылкой
в `README.md` и `CLAUDE.md`
- [x] 6.3 `docs/conventions/config.md`: локальный вход описан настройками; абзац
про закомментированный петлевой адрес под подставной прокси приведён в
соответствие с формой шага 6.1 (пара петлевых адресов, закомментированная
секция имитации, рецепт одним блоком) — строкой «*Расхождение:*», если
форма образца расходится с общим правилом секции
- [x] 6.4 `CLAUDE.md`: раздел «Запреты», абзац о локальном запуске — вход идёт
настройками, второго процесса нет; раздел «Команды», строка подкоманды
`proxy` убирается
- [x] 6.5 `README.md`: инструкция локального запуска
- [x] 6.6 `docs/security.md`, «Периметр»: изъятие отладочного запуска, чем оно
держится (умолчание предохранителя, отказ старта при имитации без него,
шаблон выкладки) и что боевая поломка машиной не исключена. Там же —
пункт «Машина разработчика и то, что он на ней поднимает»: подкоманды
`proxy` больше нет
- [x] 6.7 `docs/architecture.md`: строка таблицы про оснастку разработчика (без
прокси) и новый слой в перечне слоёв транспорта
- [x] 6.8 `docs/passport.md`, «Управление учётными записями»: сегодня раздел
утверждает, что кто пришёл, сервис не решает никогда, и что исключений у
этого больше нет. Назвать изъятие одной строкой с его границей —
«держится предохранителем `[server] debug`, выключенным по умолчанию» — и
ссылкой на спеку `access`
- [x] 6.9 Записать норму о подставных собеседниках — решение владельца, работа
сверх прежнего перечня шагов. Одной строкой в документ канона: подставной
собеседник живёт в коде или в оснастке, а в боевом бинарнике появляется
только отдельным решением владельца и только под ключом, названным своим
предметом. Сегодня таких два — подмена распознавания правкой
`internal/adapter/recognizer/memory.go` и имитация заголовков конфигом, —
а записанной нормы нет вовсе, и `docs/review.md` этот пробел отмечает
вопросом темы `operations`. Дом нормы — `docs/architecture.md`, «Принципы»:
она называет, где живёт часть системы, а не как оформляют код. Запись
вопроса в `docs/review.md` правится строкой о закрытом пробеле
## 7. Приёмка
- [x] 7.1 `task gate` зелёный
- [x] 7.2 Проверить руками: сервис поднят с заполненной имитацией, приложение
открыто по адресу сервиса, второго процесса нет
- [x] 7.3 Проверить руками **записанную форму рецепта**: скопировать свежий
`config.example.toml`, применить рецепт шага 6.2 дословно и ничего сверх
него, поднять сервис, открыть приложение. Отказ старта на полпути —
поломка рецепта, а не копии
- [x] 7.4 Критерии приёмки из блока ниже проверены поимённо
## Критерии приёмки
Рубрика ревью дизайна. Проверяется поимённо шагом 7.4.
- [x] 1. **Fail-closed по умолчанию.** Отсутствие новых ключей в существующем
конфиге не меняет ни одного байта поведения; умолчание предохранителя —
«выключено»; ни одна ошибка разбора не даёт «включено». Проверяемо: конфиг
сегодняшнего дня, не тронутый, даёт ту же цепочку слоёв и те же ответы.
- [x] 2. **У каждой комбинации новых ключей назван исход, а боевая поломка
названа поимённо и не выдана за исключённую.** Машина её не исключает:
сервис с включённым предохранителем и заполненной имитацией поднимается на
любом перечне доверенных адресов, боевом в том числе, и называет пришедшего
сам всякому, чей запрос пришёл через обратный прокси. Защита названа
поимённо и целиком — умолчание предохранителя «выключено», отказ старта при
заполненной имитации без предохранителя, боевой конфиг, рендеримый шаблоном
Ansible, — и ни один документ не утверждает, что этого набора хватает на
механическую несовместимость.
- [x] 3. **Подстановка заменяет, а не дополняет.** Слой либо владеет всем
набором заголовков входа целиком, либо не трогает запрос; второго значения
того же заголовка он не создаёт никогда. Проверяемо: запрос с двумя
`Remote-User` остаётся отказом, запрос с чужим `Remote-Email` и без
`Remote-User` не собирает личность из двух источников.
- [x] 4. **Барьер доверенного адреса подставленный заголовок проходит наравне с
пришедшим, и предикат доверия у обоих один дом.** Проверяемо: запрос с
недоверенного адреса не узнаётся при включённом предохранителе так же, как
при выключенном; отбор адреса берётся из той же точки, что и у узнавания,
а не пишется вторым списком.
- [x] 5. **Отлаживается боевая ветка.** Узнавание не получает ни второго
источника значений, ни ветки «если отладка»; к моменту чтения заголовок
неотличим от поставленного прокси. Проверяемо: файл узнавания изменением
не тронут.
- [x] 6. **Область слоя равна области узнавания и выводится из неё, а не
перечисляется вторым списком.** Проверяемо: проба здоровья, метрики и
раздача приложения отвечают одинаково при включённом и выключенном
предохранителе.
- [x] 7. **Матрица отказов старта полна и тотальна: у каждой комбинации новых
ключей назван исход, и «поднялся, но никого не узнаёт» среди исходов
нет.** Отказ идёт до приёма трафика, называет имя ключа и не называет
значения. Проверяемо перечислением: предохранитель × (пустая / заполненная
/ заполненная негодно) имитация, и отдельной строкой — два ключа секции,
дающие одно каноническое имя заголовка: `Remote-User` и `remote-user`
различимы для TOML и неразличимы для HTTP, и одно из значений терялось бы
молча.
- [x] 8. **Настройка, при которой узнавание не может состояться ни при каком
запросе, — отказ старта, а не запуск.** Имитация без ключа логина, пустое,
пробельное, слишком длинное значение и значение с управляющими знаками
судятся на старте тем же правилом, каким узнавание судит пришедшее
значение. Проверяемо: конфиг с логином в 300 знаков даёт отказ старта с
именем ключа, а не сервис, отвечающий отказом на всё.
- [x] 9. **Отладочный запуск громкий и различимый.** Одна строка при старте (без
значений) и различимость «узнан подстановкой» от «узнан прокси» на уровне
запроса. Отдельно — путь, на котором подстановка не сработала: у него
обязан быть след, иначе самый частый локальный отказ (браузер пришёл
`::1`, конфиг называет `127.0.0.1`) не отличим от поломки узнавания.
- [x] 10. **Слой без состояния между запросами: исход запроса — функция конфига
и самого запроса.** Ничего не кэширует, ничего не заводит, в контекст не
пишет; заведение учётной записи остаётся там, где было, и параллельные
первые запросы с одним логином не зависят от порядка. Проверяемо: два
одновременных первых запроса дают одну учётную запись, а не две и не
отказ.
- [x] 11. **Предохранитель — закрытый перечень следствий, а не режим.** Названо,
чего он не включает; новое следствие требует отдельной нормы. Проверяемо:
включённый предохранитель при пустой имитации ведёт себя ровно как
выключенный, включая ограничитель частоты и уровень журнала.
- [x] 12. **Записанный рецепт и объявленная граница совпадают с задуманным.**
Форма, записанная в образце конфига и в памятке, — та самая, которую
проверяют; ни один документ канона не продолжает утверждать отсутствие
механизма, который изменение заводит. Проверяемо: свежая копия
`config.example.toml` доводится до рабочего локального входа ровно теми
правками, что записаны, и не даёт отказа старта на полпути.
+314
View File
@@ -72,6 +72,22 @@
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` доверенным источником становятся настройки сервиса: сервис
называет пришедшего сам, никого не спросив. Это единственное место, где имя
берётся не от провайдера. Условия, при которых источник этот законен, стоят
требованиями «Отладочный запуск называет пришедшего настройками», «Настройка,
открывающая вход всем, роняет старт», «Отладочный запуск виден в журнале» и
«Предохранитель отладки включает только подстановку заголовков»; снимать их
поодиночке нельзя: барьер держится всеми разом.
Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию
выключен, а заполненная имитация без него роняет старт. Боевой перечень
доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с
включённым предохранителем и заполненной имитацией, назовёт своим именем всякого,
чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным
путём.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
@@ -86,6 +102,12 @@
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
#### Scenario: Без отладочного запуска имя приходит только от провайдера
- **GIVEN** предохранитель отладки выключен
- **WHEN** запрос приходит с доверенного адреса без заголовка
- **THEN** сервис никого не узнаёт и своего имени пришедшему не назначает
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
@@ -251,6 +273,17 @@ MUST отвечать отказом `401`, когда пришедший не
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` заголовок входа ставит не прокси, а сам сервис значением из
секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает. Условия, при которых источник этот законен,
и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск
называет пришедшего настройками», «Настройка, открывающая вход всем, роняет
старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает
только подстановку заголовков». Держится изъятие умолчанием предохранителя
«выключено» и отказом старта при заполненной имитации без него; боевой перечень
доверенных адресов включению предохранителя не мешает.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
@@ -554,3 +587,284 @@ MUST не выдавать вовсе — ни куки, ни токена се
- **WHEN** сервис поднимается с заполненным перечнем
- **THEN** журнал подъёма называет доверенные адреса
### Requirement: Отладочный запуск называет пришедшего настройками
Сервис SHALL подставлять запросу заголовки входа значениями из секции
`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST
делать это так, чтобы узнавание не отличало подставленный заголовок от
поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана
быть той же, что работает в бою: отладке подлежит боевой путь, а не его
отладочный двойник.
Подстановка MUST происходить только при **отсутствии** заголовка `Remote-User` в
запросе. Заголовок, пришедший в любом числе значений, MUST оставаться нетронутым:
узнавание отвергает запрос с более чем одним значением, и подстановка, дописавшая
второе, превратила бы законный отказ в проход.
Подстановка MUST происходить только тогда, когда адрес соединения попадает в
перечень `[auth] trusted_proxies`, и адрес этот MUST судиться **той же
функцией**, какой судит его узнавание: и разбор адреса пира, и сверка с
перечнем берутся из одного дома, а не пишутся вторым списком. Второй перечень
разошёлся бы с первым молча — так же, как разошлись бы два списка имён
заголовков. Второго барьера у подстановки нет и не заводится: круг тех, кто
вправе назвать пришедшего, уже очерчен этим перечнем.
Подстановка MUST действовать только при **непустой** секции имитации. Пустая
секция значит «подставлять нечего»: цепочка слоёв при ней та же, что при
выключенном предохранителе, и ни одного заголовка запроса слой не трогает.
Иначе включённый предохранитель при пустой секции срезал бы `Remote-Email` у
запроса, пришедшего без `Remote-User`, — поведение, которого в бою нет.
Подставляя, сервис MUST распоряжаться всей тройкой заголовков `Remote-*`
целиком: названные секцией ставятся её значением, не названные удаляются.
Запрос без `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
источников — логин свой, почту чужую.
Подстановка MUST действовать только под корнем приложения — там же, где действует
узнавание. Проба здоровья, метрики и раздача приложения её не видят.
#### Scenario: Запрос без заголовка входа получает имя из настроек
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса без единого заголовка `Remote-*`
- **THEN** сервис узнаёт пришедшего под именем из секции
- **AND** учётная запись заводится, если её не было
#### Scenario: Пришедший заголовок не подменяется
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса с заголовком `Remote-User`
- **THEN** узнавание получает значение из запроса, а не из настроек
#### Scenario: Два значения заголовка остаются отказом
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с двумя значениями заголовка `Remote-User`
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Недоверенный адрес имени не получает
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос без заголовка приходит с адреса вне перечня доверенных
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Чужой заголовок почты не смешивается со своим логином
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и не
называет `Remote-Email`
- **WHEN** запрос приходит без `Remote-User`, но с заголовком `Remote-Email`
- **THEN** узнавание получает логин из настроек и не получает адреса почты вовсе
#### Scenario: Пустая секция имитации заголовков не трогает
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без `Remote-User`, но с
заголовком `Remote-Email`
- **THEN** слой заголовков не трогает, и `Remote-Email` доходит до узнавания
нетронутым
#### Scenario: Выключенный предохранитель имён не раздаёт
- **GIVEN** предохранитель выключен, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без заголовка `Remote-User`
- **THEN** запрос остаётся неузнанным
#### Scenario: Наблюдение подстановки не видит
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ тот же, что и при выключенном предохранителе
### Requirement: Настройка, открывающая вход всем, роняет старт
Сервис SHALL отказываться подниматься на настройках, при которых отладочный вход
становится открытым входом, и MUST называть в отказе имя ключа. Проверка идёт на
старте, до приёма трафика: настройка, отданная на честность выкладки, проверяется
только тем, что чужой архив уже уехал не тому.
Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server]
debug` MUST читаться как выключенный предохранитель, а отсутствие секции
`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый
ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST
кончаться отказом старта, а не прочтением «включено».
Секция имитации MUST считаться **непустой**, как только в ней назван хотя бы
один ключ, каким бы ни было его значение. `Remote-User = ""` — заполненная
секция, а не пустая: человек, написавший ключ, имитацию завёл, и пустое значение
у него — вторая поломка, а не отсутствие первой.
Отказом MUST быть каждый из четырёх случаев.
Первый — секция имитации заполнена при выключенном предохранителе. Состояние это
не читается никак: либо человек забыл включить предохранитель и будет искать
поломку везде, кроме одного ключа, либо забыл убрать имитацию из боевого файла — и
тогда до открытого архива остаётся одно слово. Молчаливое игнорирование и
предупреждение в журнале оба оставляют вторую поломку жить.
Второй — секция имитации называет ключ, не совпадающий ни с одним заголовком
входа, который сервис читает. Перечень принимаемых имён MUST порождаться теми же
именами заголовков, которыми пользуется узнавание, а не перечисляться вторым
списком; сравнение MUST идти по каноническому виду имени, потому что в HTTP имя
нечувствительно к регистру, а ключ конфига чувствителен. Опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа.
Третий — секция имитации непуста, а годного `Remote-User` в ней нет. Ключ
логина MUST быть назван: секция, называющая один `Remote-Email`, поднимает
сервис, который подставит почту, удалит логин и не узнает никого. Значение
логина MUST проходить **тот же приём**, каким узнавание судит пришедшее
значение (`internal/entity.AcceptProviderLogin`): пустое, из одних пробельных
знаков, сверх предела длины и с управляющими знаками — отказ старта. Иначе
сервис поднимается, ставит заголовок, получает отказ приёма и отвечает
неузнанным на всё, оставляя за собой одну отладочную строку. Оба состояния — то
самое «не читается никак», ради которого заведён первый случай.
Четвёртый — два ключа секции дают одно каноническое имя заголовка. `Remote-User`
и `remote-user` для TOML — два ключа, для HTTP — одно имя: сервис подставил бы
одно значение, а второе потерял бы молча, и человек искал бы поломку в значении,
которого сервис не читал вовсе. Отказ MUST называть секцию и причину, а значений
MUST не называть.
Включённый предохранитель при пустой секции имитации отказом MUST не быть: сам по
себе он ничего не включает.
#### Scenario: Имитация без предохранителя не поднимается
- **GIVEN** секция имитации заполнена, предохранитель выключен
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Неизвестное имя заголовка не поднимается
- **GIVEN** предохранитель включён, секция имитации называет ключ, которого нет
среди читаемых заголовков входа
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет принимаемые имена
#### Scenario: Имитация без ключа логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет только
`Remote-Email`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя недостающего ключа логина
#### Scenario: Два ключа одного заголовка не поднимаются
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и
`remote-user`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет секцию, в которой два ключа дали одно имя
заголовка
#### Scenario: Негодное значение логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
значением в 300 знаков
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа, а не его значение
#### Scenario: Пустое значение логина считается заполненной имитацией
- **GIVEN** предохранитель выключен, секция имитации называет `Remote-User = ""`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Конфиг без ключа предохранителя ведёт себя как с выключенным
- **GIVEN** конфиг не называет ни `[server] debug`, ни секции имитации
- **WHEN** сервис запускают
- **THEN** сервис поднимается, подстановки не заводит и никого сам не называет
#### Scenario: Предохранитель без имитации поднимается
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис запускают
- **THEN** сервис поднимается
### Requirement: Отладочный запуск виден в журнале
Сервис SHALL сообщать владельцу о включённой подстановке заголовков строкой
журнала при старте и MUST не печатать при этом ни одного подставляемого значения.
Сервис, называющий пришедшего сам, — это ровно то «может стать проблемой», ради
которого заведён предупреждающий уровень: строка старта идёт на `WARN`.
Строка старта MUST нести имена подставляемых заголовков и MUST не нести их
значений. Логин — ключ к чужому архиву, и запрет на печать значения, дающего
доступ, действует на подставленное значение наравне с пришедшим.
Запрос, которому заголовки подставлены, SHALL писаться на уровне `INFO`:
этой строкой «узнан подстановкой» отличается от «узнан прокси». Боевой журнал
она не топит по построению — пишется только там, где подстановка работает, а в
бою предохранитель выключен умолчанием; на отладочном уровне её не увидел бы
никто, потому что настройки под уровень журнала у сервиса нет и он зашит `INFO`.
Строка MUST нести адрес пира и не нести подставленных значений.
Запрос, которому подставить нельзя из-за недоверенного адреса, MUST оставлять
**свою** строку предупреждающим уровнем. Узнавание на этом месте предупреждения
не пишет: подстановка работает при отсутствии `Remote-User`, а отсутствие
заголовка узнавание считает случаем штатным и пишет о нём отладочную строку;
предупреждение о недоверенном адресе оно бережёт для заголовка, который
**пришёл**. Без своей строки самый частый локальный отказ — браузер пришёл
с `::1`, а перечень называет `127.0.0.1` — не отличим от поломки узнавания:
человек видит `401` на всём приложении, заполненную секцию имитации и
включённый предохранитель. Строка MUST нести адрес пира и MUST не нести
подставляемых значений.
#### Scenario: Старт с подстановкой предупреждает владельца
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** сервис поднимается
- **THEN** журнал несёт предупреждение с именами подставляемых заголовков
#### Scenario: Подставленного значения нет в журнале
- **GIVEN** предохранитель включён, секция имитации называет логин и адрес почты
- **WHEN** сервис поднимается и принимает запрос без заголовка
- **THEN** ни логин, ни адрес почты не встречаются ни в одной журнальной записи
#### Scenario: Отказ подстановки на недоверенном адресе виден в журнале
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос без заголовка `Remote-User` приходит с адреса вне перечня
доверенных
- **THEN** журнал несёт предупреждение с адресом пира
- **AND** подставляемых значений в строке нет
#### Scenario: Подстановка на запросе видна при боевом уровне журнала
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос с доверенного адреса получает подставленные заголовки
- **THEN** строка об этом имеет уровень `INFO` и видна журналу, настроенному
по-боевому
### Requirement: Предохранитель отладки включает только подстановку заголовков
Предохранитель `[server] debug` SHALL менять одно поведение сервиса — подстановку
заголовков входа — и MUST не менять никакого другого. Перечень следствий закрыт, и
новое следствие вешается на этот ключ только отдельным требованием спеки: ключ,
названный общим словом, иначе обрастает всем подряд, и выключить его перестаёт
означать «сервис ведёт себя как в бою».
Сам по себе включённый предохранитель не включает и подстановки: она MUST
действовать только при непустой секции имитации, и при пустой цепочка слоёв MUST
быть та же, что при выключенном предохранителе, — требование «Отладочный запуск
называет пришедшего настройками».
Включённый предохранитель MUST не менять уровень журнала, не добавлять в ответы
тексты внутренних отказов и следы стека, не выводить тела запросов и ответов
внешних сервисов, не снимать и не ослаблять ограничитель частоты, не подменять
распознаватель, не ослаблять ни одной проверки старта и не открывать неузнанному
ни одного адреса.
#### Scenario: Включённый предохранитель без имитации ничего не меняет
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис принимает запросы
- **THEN** он ведёт себя ровно так же, как с выключенным предохранителем
#### Scenario: Ограничитель частоты работает и в отладочном запуске
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запросы идут чаще дозволенного
- **THEN** ограничитель частоты режет их так же, как при выключенном
предохранителе