- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
42 KiB
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 не делать. Кто допущен, определяет правило провайдера на домен сервиса — настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер общий для контура, и правило, настроенное слишком широко, открывает сервис всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя, поэтому граница названа здесь и повторена в модели угроз.
Цена сдвинулась в нашу пользу: провайдер судит каждый запрос, а не только первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок жизни этого значения. Теперь отзыв действует со следующего запроса.
Обратная сторона у этого одна, и она названа прямо: весь барьер держится на том, что прокси ставит заголовок сам, а не пропускает пришедший. Прокси, пропускающий чужой заголовок, открывает сервис всякому под любым именем. Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
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: Чужой файл неотличим от несуществующего
- 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 не заводить
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
без этой нормы все неназванные собрались бы в одну учётную запись с общим
архивом. Запрос, несущий более одного значения 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 журнал подъёма называет доверенные адреса