Files
transcriber/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/access/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

31 KiB

MODIFIED Requirements

Requirement: Значение, дающее доступ, не печатается

Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка, которым назван пришедший, ни адрес почты пользователя.

Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её не убрать. Требование того же рода, что и запрет писать имя файла на диске: там строка журнала собирала бы путь к чужой записи, здесь — имя, которым довольно назваться, чтобы стать этим человеком.

Короткий токен файла из перечня ушёл вместе с самим токеном: значений на предъявителя сервис больше не выдаёт, и запрет остался бы правилом без предмета.

Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из заголовка — тоже: это логин человека у провайдера.

Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом, доступа сам по себе не даёт и без него путь запроса не прослеживается.

Scenario: Значения заголовка нет в журнале

  • WHEN запрос с заголовком проходит через сервис
  • THEN значение заголовка не встречается ни в одной журнальной записи

Scenario: Адреса почты нет в журнале

  • WHEN приходит первое обращение и учётная запись заводится
  • THEN адрес почты не встречается ни в одной журнальной записи

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

REMOVED Requirements

Requirement: Иных способов открыть сессию нет

Reason: Требование выключало собственные входы встроенного хранилища — создание записи в коллекции пользователей, вход по паролю, вход по одноразовому коду, обмен кода у внешнего провайдера, восстановление доступа — и закрывало правилами доступа правку этой коллекции. Хранилище уходит из проекта целиком: ни одного из этих адресов не существует, и выключать больше нечего.

Migration: Единственный способ представиться остаётся прежним — заголовок доверенного источника на каждом запросе, требование «Пришедшего называет доверенный источник». Что учётную запись заводит только код сервиса и что её ключ не правится снаружи, нормирует требование «Учётная запись заводится первым обращением»: адреса правки у сервиса нет вовсе.