# access Specification ## Purpose Кто пришёл в сервис и пускают ли его дальше: узнавание по заголовку доверенного источника, заведение учётной записи первым обращением и то, какие адреса остаются открытыми неузнанному. Своего входа у сервиса нет. Собственный протокол OIDC — с адресом, уводящим к провайдеру, возвратом, кукой сессии и её сроком — жил здесь с 2026-08-12 по 2026-08-22 и убран задачей `trusted-header-login`: пришедшего называет обратный прокси, сходивший к провайдеру, и делает это на каждом запросе. Здесь же разграничение записей по владельцу: с 2026-08-14 узнавание отвечает не только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба принадлежит тому, кто её принёс, и чужая неотличима от несуществующей. Записи без владельца у сервиса не бывает: колонка владельца пустого значения не принимает, и норму эту держит capability `storage`. Прежде такие записи заводил вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран вместе с этим исключением. ## Requirements ### Requirement: Значение, дающее доступ, не печатается Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка, которым назван пришедший, ни адрес почты пользователя. Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её не убрать. Требование того же рода, что и запрет писать имя файла на диске: там строка журнала собирала бы путь к чужой записи, здесь — имя, которым довольно назваться, чтобы стать этим человеком. Короткий токен файла из перечня ушёл вместе с самим токеном: значений на предъявителя сервис больше не выдаёт, и запрет остался бы правилом без предмета. Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из заголовка — тоже: это логин человека у провайдера. Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом, доступа сам по себе не даёт и без него путь запроса не прослеживается. #### Scenario: Значения заголовка нет в журнале - **WHEN** запрос с заголовком проходит через сервис - **THEN** значение заголовка не встречается ни в одной журнальной записи #### Scenario: Адреса почты нет в журнале - **WHEN** приходит первое обращение и учётная запись заводится - **THEN** адрес почты не встречается ни в одной журнальной записи ### Requirement: Кого пускать, решает провайдер Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки допуска MUST не делать. Кто допущен, определяет правило провайдера на домен сервиса — настройка выкладки, лежащая вне репозитория. Требование записано именно как решение с ценой, а не как умолчание: провайдер общий для контура, и правило, настроенное слишком широко, открывает сервис всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя, поэтому граница названа здесь и повторена в модели угроз. Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок жизни этого значения. Теперь отзыв действует со следующего запроса. Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси, пропускающий чужой заголовок, открывает сервис всякому под любым именем. Требование к контуру записано в модели угроз; репозиторием оно не проверяется. **Изъятие одно — отладочный запуск.** При включённом предохранителе `[server] debug` доверенным источником становятся настройки сервиса: сервис называет пришедшего сам, никого не спросив. Это единственное место, где имя берётся не от провайдера. Условия, при которых источник этот законен, стоят требованиями «Отладочный запуск называет пришедшего настройками», «Настройка, открывающая вход всем, роняет старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает только подстановку заголовков»; снимать их поодиночке нельзя: барьер держится всеми разом. Держится изъятие умолчанием предохранителя «выключено» и отказом старта при заполненной имитации без него. Что остаётся между боевой выкладкой и открытым входом целиком, включая опору вне репозитория, перечисляет модель угроз — `docs/security.md`, «Периметр». #### Scenario: Названный провайдером получает доступ - **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным прокси - **THEN** доступ открывается, а учётная запись заводится, если её не было - **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа и адреса почты #### Scenario: Отзыв у провайдера действует со следующего запроса - **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ - **WHEN** приходит следующий его запрос - **THEN** прокси заголовка не ставит, и запрос получает отказ #### Scenario: Без отладочного запуска имя приходит только от провайдера - **GIVEN** предохранитель отладки выключен - **WHEN** запрос приходит с доверенного адреса без заголовка - **THEN** сервис никого не узнаёт и своего имени пришедшему не назначает ### Requirement: Проба здоровья и метрики остаются открытыми Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы здоровья, ни у сборщика метрик учётной записи нет, и требование узнавания остановило бы наблюдение за сервисом. Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и сервис на неё не полагается: содержимого записей и текстов расшифровок оба адреса не несут. Заголовок, пришедший с недоверенного адреса, MUST не менять их ответа: узнавание отказа не выдаёт, и посторонняя строка в запросе не вправе гасить наблюдение. #### Scenario: Проба здоровья доступна неузнанному - **WHEN** запрос приходит на `GET /health` без заголовка - **THEN** ответ имеет код `200` #### Scenario: Метрики доступны неузнанному - **WHEN** запрос приходит на `GET /metrics` без заголовка - **THEN** ответ имеет код `200` #### Scenario: Чужой заголовок наблюдения не гасит - **WHEN** запрос на `GET /health` приходит с недоверенного адреса с заголовком `Remote-User` - **THEN** ответ имеет код `200` ### Requirement: У записи есть владелец, и чужую ей не отдают Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от имени которой запись принята, — и MUST отдавать данные такой записи только её владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни запросом к базе: колонка владельца пустого значения не принимает, и норму эту держит capability `storage`. Владелец назначается один раз, при приёме, и MUST не меняться: совместного доступа, ролей и передачи записи другому сервис не знает. Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец, пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое имя. Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей — и к её карточке, и к её тексту, и к её файлу. Отдельный отказ «доступ запрещён» превращает чтение в перебор: по разнице ответов считывается, какие записи заведены, а идентификатор записи и есть то, что разграничение прячет. Каким именно ответом это выражено, нормирует capability `archive`: там живут адреса чтения записи, и держатель нормы обязан быть один. Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны **спрашивающего** и остаётся в силе, хотя записей без владельца в базе не бывает: спрашивающий с пустым владельцем — это вызов, у которого нет учётной записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от схемы намеренно: схема запрещает **заводить** ничью запись, а это правило запрещает **спрашивать** ничьим именем, и одно другое не заменяет. #### Scenario: Своя запись доступна - **GIVEN** человек узнан и принял запись - **WHEN** он спрашивает карточку этой записи - **THEN** ответ несёт данные записи #### Scenario: Чужая запись неотличима от несуществующей - **GIVEN** запись принята одним узнанным - **WHEN** её карточку спрашивает другой узнанный - **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом #### Scenario: Чужой файл неотличим от несуществующего - **GIVEN** запись принята одним узнанным - **WHEN** её файл просит другой узнанный - **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом #### Scenario: Владельца не задают запросом - **WHEN** запрос на приём записи несёт своё значение владельца - **THEN** владельцем принятой записи становится узнанный предъявитель #### Scenario: Ничью запись завести нечем - **WHEN** запись пытаются завести с пустым владельцем - **THEN** база её не сохраняет #### Scenario: Пустой владелец не открывает ничего - **GIVEN** заведены две записи: своя и чужая - **WHEN** карточку каждой спрашивают с пустым владельцем - **THEN** ответ на обе тот же, что и на неизвестный идентификатор ### Requirement: Приложение узнаёт вошедшего Сервис SHALL отдавать приложению сведения о том, кто пришёл, — `GET /app/me` — и MUST отвечать отказом `401`, когда пришедший не узнан. Своей страницы со скриптом, которой сервер отрисовал бы имя пришедшего, у сервиса нет: приложение собирает разметку само и пришедшего узнаёт ответом. Заголовок ставит прокси, и прочитать его из браузера приложение не может вовсе — этот адрес единственный способ узнать, кто пришёл. Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями `id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает ему выходить наружу наравне с журналом. #### Scenario: Пришедший узнан - **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User` - **WHEN** приложение спрашивает, кто пришёл - **THEN** ответ несёт идентификатор его учётной записи #### Scenario: Пришедший не узнан - **WHEN** приложение спрашивает, кто пришёл, без заголовка - **THEN** ответ имеет код `401` - **AND** тело ответа не несёт учётной записи #### Scenario: Адреса почты в ответе нет - **GIVEN** пришедший узнан, и у его учётной записи есть адрес почты - **WHEN** приложение спрашивает, кто пришёл - **THEN** адреса почты в ответе нет ### Requirement: Приложение отдаётся без сессии Сервис SHALL отдавать разметку приложения и её ресурсы неузнанному. Перечень адресов, открытых без узнавания, пополняется ими: прежде в нём стояли только проба здоровья и метрики. Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал, до приложения не доходит вовсе. Разметка при этом обязана отдаваться и ему — иначе неудача узнавания выглядела бы поломкой сервиса, а не отказом входа. Цена открытости названа здесь же и невелика: ни разметка, ни ресурсы содержимого записей не несут — они одинаковы для всех и собираются до всякого запроса. Открытость MUST не касаться данных: всякий адрес под корнем приложения по-прежнему требует узнанного предъявителя, и приложение, открытое неузнанным, не получает ни одной записи. #### Scenario: Разметка доступна неузнанному - **WHEN** запрос приходит на корень сервиса без заголовка - **THEN** ответ имеет код `200` - **AND** тело ответа — разметка приложения #### Scenario: Ресурс приложения доступен неузнанному - **WHEN** запрос приходит на ресурс приложения без заголовка - **THEN** ответ имеет код `200` #### Scenario: Данные неузнанному не отдаются - **GIVEN** приложение открыто без заголовка - **WHEN** оно спрашивает список записей - **THEN** ответ имеет код `401` ### Requirement: Пришедшего называет доверенный источник Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса не остаётся. **Изъятие одно — отладочный запуск.** При включённом предохранителе `[server] debug` заголовок входа ставит не прокси, а сам сервис значением из секции `[auth.test_headers]`; узнавание при этом остаётся тем же и подставленного заголовка от пришедшего не отличает. Условия, при которых источник этот законен, и проверки старта, которыми он держится, перечислены требованием «Кого пускать, решает провайдер». Держится изъятие умолчанием предохранителя «выключено» и отказом старта при заполненной имитации без него. Что остаётся между боевой выкладкой и открытым входом целиком, включая опору вне репозитория, перечисляет модель угроз — `docs/security.md`, «Периметр». Заголовку сервис 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** значение заголовка не встречается ни в одной журнальной записи ### Requirement: Учётная запись заводится первым обращением Сервис SHALL заводить учётную запись при первом обращении с новым значением `Remote-User` и MUST находить её по тому же значению при каждом следующем. Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой таблицы пользователей. Имя и адрес почты MUST браться из заголовков того же запроса, и только при заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по пределу колонки и чистится от управляющих знаков, негодный адрес почты отбрасывается. Негодное значение необязательного поля MUST не отменять заведения записи — иначе человек с длинным именем у провайдера не завёлся бы никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди его работы. Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение с чужим адресом досталось бы чужой записи. **Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.** Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи назначается один раз и не меняется. Держится это тем, что адреса, которым учётная запись правится снаружи, у сервиса нет вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей запись библиотечным правилом, не осталось. Правку остаётся сделать запросом к базе, и это работа владельца сервиса, а не спрашивающего. Одновременные первые обращения одним значением MUST кончаться одной учётной записью: уникальность держит схема, а не порядок обращений. **Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой колонке — это гонка двух первых обращений одним именем, и он MUST кончаться повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться заведением записи **без почты**: она необязательна. Без этого разреза второй человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск по имени снова ничего не находит. Цена ключа называется целиком, обеими сторонами. Переименование пользователя у провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней; слить их или убрать нечем — владелец записи не меняется, а учётная запись с записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек, которому провайдер выдал логин ушедшего, при первом обращении попадает в существующую запись и получает весь её архив. Не допускать переиспользования — работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта цена принимается, а не обходится. #### Scenario: Первое обращение заводит запись - **GIVEN** учётной записи с этим значением ещё нет - **WHEN** приходит запрос с заголовком `Remote-User` - **THEN** учётная запись появляется - **AND** запрос идёт от её имени #### Scenario: Повторное обращение попадает в ту же запись - **GIVEN** учётная запись заведена первым обращением - **WHEN** приходит второй запрос с тем же значением заголовка - **THEN** новой учётной записи не появляется - **AND** запрос идёт от имени прежней #### Scenario: Разным значениям — разные записи - **WHEN** приходят запросы с двумя разными значениями заголовка - **THEN** заводятся две учётные записи - **AND** записи одного не видны другому #### Scenario: Имя не переписывается вторым обращением - **GIVEN** учётная запись заведена с одним значением `Remote-Name` - **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name` - **THEN** имя учётной записи остаётся прежним #### Scenario: Два одновременных первых обращения дают одну запись - **GIVEN** учётной записи с этим значением ещё нет - **WHEN** два запроса с одним значением заголовка приходят одновременно - **THEN** в таблице пользователей появляется ровно одна строка - **AND** оба запроса идут от её имени #### Scenario: Занятая почта не мешает завести запись - **GIVEN** учётная запись с этим адресом почты уже заведена - **WHEN** приходит первое обращение с другим `Remote-User` и тем же `Remote-Email` - **THEN** заводится своя учётная запись - **AND** адреса почты у неё нет #### Scenario: Адреса правки учётной записи у сервиса нет - **GIVEN** человек узнан и его учётная запись заведена - **WHEN** ищут адрес сервиса, которым он правит свою учётную запись - **THEN** такого адреса нет #### Scenario: Негодное имя не отменяет заведения - **GIVEN** учётной записи с этим значением ещё нет - **WHEN** приходит обращение с именем длиннее предела колонки - **THEN** учётная запись заводится, а имя обрезано по пределу #### Scenario: Негодная почта отбрасывается, а не отменяет заведение - **GIVEN** учётной записи с этим значением ещё нет - **WHEN** приходит обращение с адресом почты, не похожим на адрес - **THEN** учётная запись заводится без почты #### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит - **GIVEN** бюджет ограничителя частоты выбран - **WHEN** приходит обращение с новым значением заголовка - **THEN** ответ несёт отказ ограничителя - **AND** учётной записи не появляется #### Scenario: Заведение учётной записи видно в журнале - **WHEN** приходит первое обращение с новым значением заголовка - **THEN** журнал несёт строку о заведении с идентификатором записи - **AND** значения заголовка в ней нет ### Requirement: Доверенный источник объявлен настройкой Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт, когда перечень пуст либо его строки не читаются как адрес или подсеть. Пустой перечень значит «не верить никому»: сервис поднялся бы никого не узнающим, а узнать об этом было бы неоткуда. Отказ старта MUST называть имя ключа. Ни адресов провайдера, ни идентификатора клиента, ни секрета клиента в конфиге MUST не быть: менять код больше не на что, и секрет уходит из конфига вместе с протоколом. Перечень MUST называться строкой журнала при подъёме. Сервис, никого не узнающий из-за неверного перечня, иначе неотличим от сервиса, до которого заголовок не доходит вовсе, — а это разные поломки в разных местах. #### Scenario: Пустой перечень роняет старт - **WHEN** сервис поднимается с пустым перечнем доверенных адресов - **THEN** старт кончается отказом - **AND** отказ называет имя ключа #### Scenario: Негодная строка перечня роняет старт - **WHEN** сервис поднимается с перечнем, где строка не читается как адрес или подсеть - **THEN** старт кончается отказом #### Scenario: Перечень виден в журнале подъёма - **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** ограничитель частоты режет их так же, как при выключенном предохранителе