# 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 не давать доступа и MUST не менять учётной записи. Собственное создание записи в коллекции пользователей, вход по паролю, вход по одноразовому коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть выключены настройкой коллекции. **Закрывается не только вход, но и правка.** Перечисление, чтение, создание, правка и удаление записи коллекции пользователей MUST быть закрыты правилами доступа — то есть оставлены пустыми, что у хранилища означает «только владелец панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил, панель работает суперпользователем, своих экранов профиля сервис не заводит — закрытие не стоит ничего. Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание хранилища заводит коллекцию пользователей с открытым созданием записи и включённым входом по паролю, и без этого требования узнавание по заголовку обходится двумя запросами: завести себе запись, войти по паролю, предъявить полученное. Отдельная цена у открытого создания записи — захват учётной записи: запись, заведённая посторонним под чужим именем, досталась бы первому же настоящему обращению с этим именем. Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему не судья. #### Scenario: Завести учётную запись самому нельзя - **WHEN** запрос снаружи создаёт запись в коллекции пользователей - **THEN** ответ несёт отказ, а записи не появляется #### Scenario: Обращение с заголовком запись заводит - **GIVEN** учётной записи с этим значением ещё нет - **WHEN** запрос с заголовком приходит с доверенного адреса - **THEN** учётная запись появляется #### Scenario: Вход паролем недоступен - **WHEN** запрос идёт на вход по паролю к коллекции пользователей - **THEN** ответ несёт отказ, а доступа не открывается #### Scenario: Обмен кода у провайдера недоступен - **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции пользователей - **THEN** ответ несёт отказ, а доступа не открывается #### Scenario: Восстановление доступа недоступно - **WHEN** запрос просит восстановление пароля или одноразовый код - **THEN** ответ несёт отказ #### Scenario: Правка учётной записи снаружи закрыта - **GIVEN** человек узнан и его учётная запись заведена - **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу - **THEN** ответ несёт отказ, а запись остаётся прежней #### Scenario: Перечисление учётных записей закрыто - **GIVEN** человек узнан - **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу - **THEN** ответ несёт отказ ### Requirement: Значение, дающее доступ, не печатается Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка, которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя. Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её не убрать. Требование того же рода, что и запрет писать имя файла в хранилище: там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым довольно назваться, чтобы стать этим человеком. Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из заголовка — тоже: это логин человека у провайдера. Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом, доступа сам по себе не даёт и без него путь запроса не прослеживается. #### Scenario: Значения заголовка нет в журнале - **WHEN** запрос с заголовком проходит через сервис - **THEN** значение заголовка не встречается ни в одной журнальной записи #### Scenario: Адреса почты нет в журнале - **WHEN** приходит первое обращение и учётная запись заводится - **THEN** адрес почты не встречается ни в одной журнальной записи ### Requirement: Кого пускать, решает провайдер Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки допуска MUST не делать. Кто допущен, определяет правило провайдера на домен сервиса — настройка выкладки, лежащая вне репозитория. Требование записано именно как решение с ценой, а не как умолчание: провайдер общий для контура, и правило, настроенное слишком широко, открывает сервис всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя, поэтому граница названа здесь и повторена в модели угроз. Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок жизни этого значения. Теперь отзыв действует со следующего запроса. Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси, пропускающий чужой заголовок, открывает сервис всякому под любым именем. Требование к контуру записано в модели угроз; репозиторием оно не проверяется. #### Scenario: Названный провайдером получает доступ - **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным прокси - **THEN** доступ открывается, а учётная запись заводится, если её не было - **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа и адреса почты #### 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: Владельца не задают запросом - **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 не вести собственного входа: ни адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса не остаётся. Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из объявленного перечня доверенных, и адрес этот 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 не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же: **короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по ссылке на файл записи; его нормирует capability `storage`, а срок его жизни назначается числом и живёт там, где проект держит числовые настройки. На этот срок — и только на него — отзыв доступа до файловой ссылки не доходит. В остальном смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не однажды выданный срок. Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок: владелец панели предъявляет свой, и подмена его учётной записью пользователя отобрала бы у него панель посреди работы. Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики. Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с адресом его почты. #### Scenario: Заголовок с доверенного адреса узнаёт человека - **GIVEN** адрес источника стоит в перечне доверенных - **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User` - **THEN** запрос идёт от имени учётной записи с этим значением #### Scenario: Заголовок с недоверенного адреса не узнаёт никого - **GIVEN** адреса источника в перечне доверенных нет - **WHEN** запрос к адресу приложения приходит с тем же заголовком - **THEN** ответ имеет код `401` - **AND** учётной записи с этим значением не появляется #### Scenario: Предъявленный токен побеждает заголовок - **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен хранилища - **WHEN** сервис решает, кто пришёл - **THEN** пришедшим считается предъявитель токена #### 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** запрос идёт с доверенного адреса с заголовком `Remote-User` - **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 кончаться одной учётной записью: уникальность держит схема, а не порядок обращений. **Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой колонке — это гонка двух первых обращений одним именем, и он 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** значения заголовка в ней нет #### Scenario: Ключ учётной записи снаружи не правится - **GIVEN** человек узнан и его учётная запись заведена - **WHEN** он правит ключ своей учётной записи запросом к хранилищу - **THEN** правка не проходит, а ключ остаётся прежним ### Requirement: Доверенный источник объявлен настройкой Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт, когда перечень пуст либо его строки не читаются как адрес или подсеть. Пустой перечень значит «не верить никому»: сервис поднялся бы никого не узнающим, а узнать об этом было бы неоткуда. Отказ старта MUST называть имя ключа. Ни адресов провайдера, ни идентификатора клиента, ни секрета клиента в конфиге MUST не быть: менять код больше не на что, и секрет уходит из конфига вместе с протоколом. Перечень MUST называться строкой журнала при подъёме. Сервис, никого не узнающий из-за неверного перечня, иначе неотличим от сервиса, до которого заголовок не доходит вовсе, — а это разные поломки в разных местах. #### Scenario: Пустой перечень роняет старт - **WHEN** сервис поднимается с пустым перечнем доверенных адресов - **THEN** старт кончается отказом - **AND** отказ называет имя ключа #### Scenario: Негодная строка перечня роняет старт - **WHEN** сервис поднимается с перечнем, где строка не читается как адрес или подсеть - **THEN** старт кончается отказом #### Scenario: Перечень виден в журнале подъёма - **WHEN** сервис поднимается с заполненным перечнем - **THEN** журнал подъёма называет доверенные адреса