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

42 KiB
Raw Blame History

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 значение заголовка не встречается ни в одной журнальной записи