хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+74 -153
View File
@@ -20,89 +20,20 @@
вход 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 читаться как ключ к чужому доступу: заголовок целиком
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
не убрать. Требование того же рода, что и запрет писать имя файла в хранилище:
там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым
довольно назваться, чтобы стать этим человеком.
не убрать. Требование того же рода, что и запрет писать имя файла на диске: там
строка журнала собирала бы путь к чужой записи, здесь — имя, которым довольно
назваться, чтобы стать этим человеком.
Короткий токен файла из перечня ушёл вместе с самим токеном: значений на
предъявителя сервис больше не выдаёт, и запрет остался бы правилом без предмета.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
заголовка — тоже: это логин человека у провайдера.
@@ -189,8 +120,8 @@
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и
норму эту держит capability `storage`.
конвейером, ни запросом к базе: колонка владельца пустого значения не принимает,
и норму эту держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
@@ -199,19 +130,19 @@
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
быть один.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей — и
к её карточке, и к её тексту, и к её файлу. Отдельный отказ «доступ запрещён»
превращает чтение в перебор: по разнице ответов считывается, какие записи
заведены, а идентификатор записи и есть то, что разграничение прячет. Каким
именно ответом это выражено, нормирует capability `archive`: там живут адреса
чтения записи, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
**спрашивающего** и остаётся в силе, хотя записей без владельца в базе не бывает:
спрашивающий с пустым владельцем — это вызов, у которого нет учётной записи, и
отвечать ему надо отказом, а не выборкой. Держится оно отдельно от схемы
намеренно: схема запрещает **заводить** ничью запись, а это правило запрещает
**спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
@@ -225,6 +156,12 @@ capability `archive`: там живут адреса чтения записи,
- **WHEN** её карточку спрашивает другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Чужой файл неотличим от несуществующего
- **GIVEN** запись принята одним узнанным
- **WHEN** её файл просит другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
@@ -233,7 +170,7 @@ capability `archive`: там живут адреса чтения записи,
#### Scenario: Ничью запись завести нечем
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
- **THEN** база её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
@@ -326,24 +263,24 @@ MUST отвечать отказом `401`, когда пришедший не
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
требованием учётной записи на адресах приложения.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает
хранилище, а на новом имени ещё и пишет в него; выполненное раньше ограничителя,
оно работало бы на запросах, которые тот уже отверг, и поток отвергнутых
обращений заводил бы учётные записи, которые потом не убираются ничем.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а
на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало
бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы
учётные записи, которые потом не убираются ничем.
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
Область — корень приложения и адрес, которым хранилище выдаёт короткий токен
файла; она MUST выводиться из объявленного адресного пространства сервиса, а не
перечисляться вторым списком. Собственная поверхность хранилища под узнавание
MUST не подпадать: правка учётной записи, её чтение и перечисление коллекции
пользователей остаются недостижимыми для узнанного, потому что ключ учётной
записи лежит там обычной колонкой, а правило правки у коллекции — библиотечное и
разрешает править свою запись. Расширение области на всё дало бы узнанному
переписать себе ключ на чужое имя и забрать чужой архив.
Область — корень приложения; она MUST выводиться из объявленного адресного
пространства сервиса, а не перечисляться вторым списком. Прежде область была
шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни
адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность
хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в
коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой
нет, и вычитать больше нечего.
Сужение области закрывает и вторую вещь: узнавание MUST не срабатывать на пробе
здоровья, на метриках и на ресурсах приложения. Иначе запрос за каждой картинкой
стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё.
Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание
MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения.
Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос
с новым именем — записи в неё.
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
@@ -360,7 +297,7 @@ MUST не подпадать: правка учётной записи, её ч
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
считает колонка.
Отказ хранилища при узнавании MUST кончаться отказом сервиса, а не молчаливым
Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
@@ -379,18 +316,17 @@ MUST не подпадать: правка учётной записи, её ч
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
MUST не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же:
**короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по
ссылке на файл записи; его нормирует capability `storage`, а срок его жизни
назначается числом и живёт там, где проект держит числовые настройки. На этот
срок — и только на него — отзыв доступа до файловой ссылки не доходит.
MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла.
Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию,
что и всё прочее, и отзыв доступа доходит до него сразу.
В остальном смысл именно таков: отзыв доступа судит провайдер на каждом
обращении, а не однажды выданный срок.
Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не
однажды выданный срок.
Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок:
владелец панели предъявляет свой, и подмена его учётной записью пользователя
отобрала бы у него панель посреди работы.
Собственных токенов сервис не принимает: значения, предъявленного запросом и
дающего доступ помимо заголовка, у него не существует. Прежде такое значение
било заголовок — им пользовался владелец панели; панели нет, и правило приоритета
осталось бы правилом без предмета.
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
@@ -410,17 +346,10 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **THEN** ответ имеет код `401`
- **AND** учётной записи с этим значением не появляется
#### Scenario: Предъявленный токен побеждает заголовок
#### Scenario: Предъявленного значения сервис не признаёт
- **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен
хранилища
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается предъявитель токена
#### Scenario: Протухший токен узнаванию не мешает
- **GIVEN** запрос несёт заголовок `Remote-User` и негодный либо протухший токен
хранилища
- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа
в заголовке или в параметре
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается названный заголовком
@@ -445,12 +374,11 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Поверхность хранилища узнаванию не подпадает
#### Scenario: Область узнавания — корень приложения
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он правит запись коллекции пользователей собственным адресом
хранилища
- **THEN** правка не проходит
- **GIVEN** сервис поднялся
- **WHEN** смотрят, на каких адресах срабатывает узнавание
- **THEN** это адреса под корнем приложения, и второго списка адресов нет
#### Scenario: Проба здоровья учётной записи не заводит
@@ -480,28 +408,28 @@ MUST не выдавать — ни куки, ни токена сессии. И
Сервис SHALL заводить учётную запись при первом обращении с новым значением
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
коллекции пользователей.
таблицы пользователей.
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
пределу колонки и чистится от управляющих знаков, негодный адрес почты
отбрасывается. Негодное значение необязательного поля MUST не отменять
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе
всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы
карточку человека молча, посреди его работы.
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное
обращение MUST не переписывать: иначе всякий запрос был бы записью в базу, а
правка имени у провайдера меняла бы карточку человека молча, посреди его работы.
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
с чужим адресом досталось бы чужой записи.
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
Ни запросом снаружи, ни рукой в панели: переписанный ключ отдаёт архив
следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи
назначается один раз и не меняется. Правило доступа коллекции пользователей MUST
закрывать правку записи снаружи наглухо, и MUST это держать схема, а не область
действия узнавания: защита, стоящая на том, что до поверхности хранилища никто не
дотянется, однажды уже оказалась случайной.
Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
его будет нечем — владелец записи назначается один раз и не меняется. Держится
это тем, что адреса, которым учётная запись правится снаружи, у сервиса нет
вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей
запись библиотечным правилом, не осталось. Правку остаётся сделать запросом к
базе, и это работа владельца сервиса, а не спрашивающего.
Одновременные первые обращения одним значением MUST кончаться одной учётной
записью: уникальность держит схема, а не порядок обращений.
@@ -553,7 +481,7 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** два запроса с одним значением заголовка приходят одновременно
- **THEN** в коллекции пользователей появляется ровно одна запись
- **THEN** в таблице пользователей появляется ровно одна строка
- **AND** оба запроса идут от её имени
#### Scenario: Занятая почта не мешает завести запись
@@ -564,11 +492,11 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **THEN** заводится своя учётная запись
- **AND** адреса почты у неё нет
#### Scenario: Ключ учётной записи не правится и рукой в панели
#### Scenario: Адреса правки учётной записи у сервиса нет
- **GIVEN** учётная запись заведена
- **WHEN** её ключ меняют сохранением записи мимо адресов приложения
- **THEN** сохранение отвергается, а ключ остаётся прежним
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** ищут адрес сервиса, которым он правит свою учётную запись
- **THEN** такого адреса нет
#### Scenario: Негодное имя не отменяет заведения
@@ -595,12 +523,6 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **THEN** журнал несёт строку о заведении с идентификатором записи
- **AND** значения заголовка в ней нет
#### Scenario: Ключ учётной записи снаружи не правится
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** он правит ключ своей учётной записи запросом к хранилищу
- **THEN** правка не проходит, а ключ остаётся прежним
### Requirement: Доверенный источник объявлен настройкой
Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт,
@@ -632,4 +554,3 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **WHEN** сервис поднимается с заполненным перечнем
- **THEN** журнал подъёма называет доверенные адреса