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

68 KiB
Raw Blame History

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 доверенным источником становятся настройки сервиса: сервис называет пришедшего сам, никого не спросив. Это единственное место, где имя берётся не от провайдера. Условия, при которых источник этот законен, стоят требованиями «Отладочный запуск называет пришедшего настройками», «Настройка, открывающая вход всем, роняет старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает только подстановку заголовков»; снимать их поодиночке нельзя: барьер держится всеми разом.

Держится изъятие умолчанием, а не машиной: предохранитель по умолчанию выключен, а заполненная имитация без него роняет старт. Боевой перечень доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с включённым предохранителем и заполненной имитацией, назовёт своим именем всякого, чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным путём.

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; узнавание при этом остаётся тем же и подставленного заголовка от пришедшего не отличает. Условия, при которых источник этот законен, и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск называет пришедшего настройками», «Настройка, открывающая вход всем, роняет старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает только подстановку заголовков». Держится изъятие умолчанием предохранителя «выключено» и отказом старта при заполненной имитации без него; боевой перечень доверенных адресов включению предохранителя не мешает.

Заголовку сервис 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 ограничитель частоты режет их так же, как при выключенном предохранителе