вход переехал на доверенный заголовок Authelia вместо собственного OIDC

- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
av
2026-08-22 20:24:22 +03:00
parent e4441f3c49
commit 7f33c957e5
63 changed files with 5257 additions and 2305 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-22
@@ -0,0 +1,361 @@
## Context
Сегодня сервис проводит вход сам: адрес `/auth/login` уводит человека к
Authelia, положив состояние и проверочный код PKCE в куку `transcriber_login`;
`/auth/callback` сверяет состояние и меняет принесённый код на сессию
внутрипроцессным запросом к собственному адресу хранилища; сессия уезжает кукой
`transcriber_session` и живёт семь суток. Секрет клиента ради этого обмена лежит
в конфиге и приводится к настройкам коллекции пользователей при каждом подъёме.
Обратный прокси контура (`files/caddyproxy/Caddyfile.template` в
`pet-project-server`) уже спрашивает Authelia через `forward_auth` и копирует
ответ в заголовки `Remote-User`, `Remote-Groups`, `Remote-Email`, `Remote-Name`
трём соседним сервисам. Правила для этого сервиса там нет: он не выложен.
Контейнер портов наружу не публикует.
Проект на стройке: данных на сервере нет, совместимость не требуется. Применённый
шаг схемы по-прежнему не переписывается.
**Что измерено пробником** на временной базе (прогон 2026-08-22, программа
удалена):
- коллекция `users` требует непустых `email` и `password`; поле `email`
переводится в необязательное шагом схемы, `password` — нет ни при каком
значении признака;
- уникальность почты держится **частичным** индексом (`WHERE email != ''`),
поэтому запись без почты законна и второй такой же не мешает;
- своя колонка с уникальным индексом отвергает вторую запись с тем же значением
и находится поиском по значению;
- запись, заведённая так, выдаёт файловый токен — то есть годится как учётная
запись предъявителя во всём, что делает сервис.
## Goals / Non-Goals
**Goals:**
- Узнавать пришедшего заголовком доверенного источника и никак иначе.
- Убрать собственный протокол входа целиком, вместе с куками, состоянием, PKCE,
обменом кода и выходом.
- Убрать секрет клиента из конфига и из базы.
- Оставить рабочим путь к файлу записи: предъявиться, взять у хранилища короткий
файловый токен, пройти по ссылке.
- Оставить рабочим вход владельца в панель его собственным паролем.
- Дать способ представиться на машине без прокси.
**Non-Goals:**
- Второй уровень доступа по `Remote-Groups`: его потребителя — страницы расхода
— ещё нет.
- Личные токены для программ: их заводит `api-tokens`.
- Правило обратного прокси и правило Authelia для домена сервиса: они живут в
`pet-project-server`, и эта работа их не пишет и не проверяет.
- Приведение пути к канонической форме (обход панели подменой знака): барьер
переезжает на домен, и правило на литерал пути перестаёт быть единственным, но
сама канонизация — не предмет этой работы.
## Decisions
### Р1. Доверие определяется адресом пира соединения
Заголовку верят, когда запрос пришёл с адреса из объявленного перечня. Адрес
берётся у самого соединения (`RemoteAddr`), а не из пересылаемых заголовков.
Отвергнуто:
- **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
запрос. Барьер, который подделывается той же строкой, что и обходится, не
барьер вовсе.
- **Готовое разрешение адреса у библиотеки** (`RealIP`). Оно читает
пересылаемый заголовок, когда настроен список доверенных прокси, а сам список
живёт в настройках хранилища — то есть в базе, а не в конфиге. Условие доверия
оказалось бы в месте, которое правят руками в панели.
- **«Контейнер портов не публикует, значит доверяем всякому».** Условие верное,
но непроверяемое отсюда: правка раскладки контейнера открыла бы сервис молча,
и оракула у второго критерия приёмки не было бы вовсе.
### Р2. Слой действует на объявленной области, а не на всём роутере
Слой вешается корневым — иначе к нужным адресам его не привязать, — но узнаёт
пришедшего **только на объявленной области**: корень приложения плюс адрес,
которым хранилище выдаёт короткий файловый токен. Область берётся из перечня
адресного пространства, а не пишется вторым списком; адрес файлового токена
добавляется к нему поимённо. Устроено это ровно так же, как сегодня устроен
запрет продления сессии: корневая привязка и проверка пути внутри.
Учётная запись ставится **только когда её ещё нет** — то есть после того, как
библиотека прочитала предъявленный токен и никого не нашла. Предъявленным
считается токен, который библиотека **прочитала успешно**: протухший и негодный
предъявленными не считаются, и такой запрос узнаётся заголовком.
**Первая редакция вешала узнавание на весь роутер, и это была дыра.** Прежняя
спека держала обратное требование — «область действия слоя MUST быть ограничена
адресами приложения… часть поверхности хранилища защищена сегодня ровно тем, что
браузер заголовка сам не шлёт, и расширение слоя на всё сняло бы эту защиту
молча», — а первая редакция снимала это требование вместе с кукой и замены ему не
давала. Сложение с Р5 (ключ учётной записи переезжает в обычную колонку
пользователей) давало путь захвата: узнанный человек шлёт
`PATCH /api/collections/users/records/{свой id}` и ставит себе ключом чужое имя,
а первое обращение настоящего владельца этого имени попадает в **его** учётную
запись — вместе со всем архивом. Правило правки у коллекции пользователей
библиотечное и разрешает править свою запись; **проверено пробником**: у
коллекции `users` `ListRule`, `ViewRule`, `UpdateRule` и `DeleteRule` равны
`id = @request.auth.id`, а применённый шаг `202608120001_oidc_login` трогает
только правило создания.
Второе следствие сужения: узнавание больше не срабатывает на пробе здоровья, на
метриках и на каждом ресурсе сборщика. Иначе всякий запрос за картинкой стоил бы
обращения к базе, а первый такой запрос с новым именем — записи в неё.
Отвергнуто: **вешать под корнем приложения и завести отдельный путь к файлу**.
Второй способ добраться до файла — второй дом одного правила, и разошлись бы они
на первой же правке разграничения.
### Р2а. Поверхность коллекции пользователей закрывается схемой
Новый шаг схемы снимает у коллекции пользователей все правила доступа —
перечисление, чтение, создание, правку и удаление, — оставляя их пустыми, то есть
«только владелец панели». Наш код читает и заводит запись мимо правил, панель
работает суперпользователем, своих экранов профиля сервис не заводит.
Это второй барьер поверх Р2, и он нужен именно вторым. Прежняя спека сама
называла защиту, державшуюся на том, что браузер не шлёт заголовка,
**случайной** — и была права; область слоя это та же случайность, только с
другой стороны. Сверку берёт на себя проверка схемы: коллекция пользователей
дописывается к шести, которые она уже сторожит, — сегодня она единственная из
семи проверяется только умолчаниями библиотеки.
Отсюда же норма, которой в первой редакции не было вовсе: **ключ учётной записи
не правится ничем, кроме заведения самим сервисом.** Правило доступа закрывает
путь снаружи, норма закрывает и правку рукой в панели: переписанный ключ отдаёт
архив следующему, кто придёт с этим именем, и вернуть его будет нечем.
### Р3. Сессия не выдаётся никакая
Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
заголовку, который прокси поставил, сходив к Authelia.
Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
значение, живущее семь суток, отозванный у провайдера человек работал до
истечения этого значения, и другого канала отзыва не было.
Отвергнуто: **выдавать сессию хранилища по первому запросу с заголовком** — куку
ставить, дальше верить ей. Дешевле в работе (слой срабатывал бы раз в неделю, а
не на каждом запросе), но возвращает ровно то, что задача убирает: значение,
переживающее отзыв. Семь суток вернулись бы вместе с ним.
Цена принятого решения: поиск учётной записи по значению заголовка идёт на
**каждом** запросе к сервису. Уникальный индекс делает это одним обращением к
базе; сервисом пользуются единицы человек, и замера здесь не требуется.
### Р4. Имена заголовков нормативны, настраивается перечень доверенных адресов
Имена `Remote-User`, `Remote-Name`, `Remote-Email` записываются в спеку и
живут константами — как жило имя куки сессии, и по той же причине: смена имени
молча перестаёт узнавать всех, а тест, который сам ставит и сам читает своё имя,
этого не замечает. Контур уже пишет эти имена трём соседним сервисам.
Настройкой приходит одно — перечень адресов, чьему заголовку верят. Он
непустой обязателен: пустой перечень значит «не верить никому», то есть сервис,
поднявшийся никого не узнающим, и молчать об этом старт не вправе.
Отвергнуто: **имена заголовков ключами конфига**. Второе место, где их можно
написать неверно, а выгоды нет: контур один и пишет одно.
Имена ключей конфига — необратимое. **Решено человеком на чекпоинте
2026-08-22:** секция `[auth]` остаётся под своим именем, в ней один ключ —
`trusted_proxies`. Довод: секция сохраняет смысл «кому мы верим на входе», а
проверка настроек на старте остаётся там же, где была.
### Р5. Ключ учётной записи — своя колонка
Значение `Remote-User` ложится в свою колонку коллекции пользователей,
уникальную. По ней запись ищется и по ней же заводится, если её ещё нет.
Отвергнуто:
- **Адрес почты ключом.** Authelia не обязана его отдавать, человек его меняет,
а первый вход с чужим адресом достался бы чужой записи — ровно тот захват,
ради которого прежний шаг схемы закрывал создание записи.
- **Таблица внешних учётных записей библиотеки.** Она принадлежит механике
OAuth2, которую эта работа убирает целиком.
- **Не брать `Remote-Email` вовсе.** Довод за: поля с личными сведениями,
у которого нет ни одного потребителя, в периметре быть не должно — ключом
почта не служит, в журнал и в ответ ей ходу нет, а ради неё шаг схемы ещё и
делает колонку необязательной. Отвергнуто потому, что потребитель назван и
ждёт своей очереди: задача `email-notification` шлёт готовый текст на адрес из
учётной записи, и строка про это стоит в модели угроз, раздел «Куда уходит
содержимое записи». Взять адрес заголовком в тот день будет нечем — заголовок
приходит с запросом человека, а рассылка идёт из конвейера.
### Р5а. Цена ключа названа в обе стороны
Переименование у провайдера заводит новую учётную запись — это первая половина, и
она была названа сразу. Вторая: **логин переиспользуем**. Человек, которому
выдали логин ушедшего, при первом же обращении попадает в **существующую** запись
и получает весь её архив — голосовые записи семьи, расшифровки, тексты, то есть
самое чувствительное, что у сервиса есть.
Записывается это в спеку и повторяется в модели угроз, а не чинится: неизменяемого
признака заголовок не приносит, и не допускать переиспользования логинов — работа
провайдера, а не сервиса. Дефект был бы в умолчании — в том, что риск не назван, и
следующий читатель счёл бы вопрос закрытым абзацем про переименование.
Третья сторона той же цены: у осиротевшей записи прежнего человека остаётся архив,
который нечем ни слить с новой, ни убрать — владелец записи назначается один раз и
не меняется, а учётная запись с записями не удаляется по норме `storage`.
Имя колонки — `provider_login`, и оно говорит о происхождении значения: это логин
человека **у провайдера**, а не наш идентификатор. Имя уезжает шагом схемы и
потому не переписывается.
Заведение записи: имя — из `Remote-Name`, почта — из `Remote-Email`, если тот
пришёл. Почта перестаёт быть обязательной (шаг схемы), пароль записи
обязателен всегда — ей ставится случайный, употребить его нельзя, потому что
вход по паролю у коллекции выключен.
Найденную запись повторное обращение **не переписывает**: имя и почта берутся
только при заведении. Иначе каждый запрос был бы записью в базу, а правка имени
в Authelia переписывала бы карточку человека молча, посреди его работы.
**Значение принимается, а не берётся как есть.** Пустое и состоящее из пробельных
знаков не узнаёт никого и записи не заводит: прокси штатно шлёт пустой заголовок
там, где никого не назвал, и по букве «первое обращение с новым значением» все
неназванные собрались бы в одну общую учётную запись с общим архивом. По той же
причине отвергается запрос, несущий **более одного** значения `Remote-User`:
прокси, настроенный добавлять заголовок вместо замены, оставляет присланный
анонимом рядом со своим, и умолчание «берём первый» отдаёт вход анониму. Сверх
того — предел длины и отказ на управляющие знаки, а в хранилище значение уходит
параметром, а не подстановкой в текст фильтра. Класс у проекта уже был: хвост
имени отправителя, уехавший меткой метрики, чинился приведением на входе, а не
запретом на выходе.
Сравнение при поиске — точное, знак в знак. Приведение регистра завело бы правило,
которого нет у провайдера: считает ли Authelia `admin` и `Admin` одним человеком,
сервису неизвестно, а угаданное правило склеило бы двух разных людей молча.
**Два отказа уникальности различаются.** По ключевой колонке — гонка двух первых
обращений одним именем: код повторяет поиск и продолжает. По любой другой —
почта, пришедшая от провайдера, уже занята другой учётной записью (общий
почтовый ящик, семья, группа) — запись заводится **без почты**, потому что почта
необязательна, и остаётся строка журнала с идентификатором записи. Без этого
разреза второй человек с той же почтой не завёлся бы никогда: повторный поиск по
имени снова ничего не нашёл бы, и исход выродился бы либо в цикл, либо в вечный
отказ без внятной причины.
**Отказ хранилища при узнавании — отказ сервиса, а не «вас не узнали».** Молчаливый
проход неузнанным показал бы человеку отказ входа там, где легла база.
### Р6. Заголовок с недоверенного адреса просто не действует
Слой при этом отказа не выдаёт. Отказ приходит там, где приходил и раньше, —
требованием учётной записи под корнем приложения, кодом `401`.
Отвергнуто: **отвечать отказом прямо в слое**. Проба здоровья, метрики и
разметка приложения открыты анонимно по спеке; отказ в слое закрыл бы их
всякому, кто пришлёт заголовок, — то есть чужая строка в запросе гасила бы
наблюдение за сервисом.
### Р7. Значение заголовка в журнал не идёт
Пишется факт и исход — «заголовок пришёл с недоверенного адреса», «учётная
запись заведена», — с идентификатором записи, но без значения. Довод тот же,
каким причина отказа провайдера приводилась к перечню известных: значением
целиком распоряжается тот, кто шлёт запрос, а с недоверенного адреса это ещё и
аноним. Плюс само имя принадлежит человеку — наравне с адресом почты, которому
спека уже запрещает попадать в журнал.
### Р7а. У правила «найти или завести человека» есть дом, и он не в транспорте
Правило — поиск по ключу, заведение при отсутствии, приём значения, разрез двух
отказов уникальности, «найденную не переписывать» — живёт **методом пакета
хранилища**, а слой транспорта только читает заголовок, судит адрес пира и зовёт
метод. Дом объявляется строкой в перечне единых точек проекта.
Причина в следующей задаче, а не в чистоте. `api-tokens` заводит **второй** способ
представиться и в своей записи прямо говорит: «два способа представиться и один
владелец». Правило, уложенное куском в слой транспорта, придётся тогда либо
продублировать вторым куском — и он разойдётся с первым на первой же правке, —
либо вытаскивать задним числом. Стоимость следующего изменения здесь считается:
при вынесенном правиле новый способ представиться стоит одного нового слоя.
Отвергнуто: **контракт в `internal/contract` с реализацией в адаптере**. Дороже на
интерфейс, у которого сегодня одна реализация и второго потребителя вне HTTP не
предвидится; правила направления зависимостей транспорту знать адаптер хранилища
разрешают.
### Р8. Способ представиться без прокси — развилка
Подставной провайдер уходит вместе с протоколом, и его место пусто. На это место
опирается `dev-run-task`, которая обещает адрес, по которому открывают
приложение. Варианты:
| Способ | Что даёт | Цена |
| --- | --- | --- |
| **Один main-пакет оснастки с подкомандами**`cmd/devtools proxy` ставит заголовок и переправляет запрос сервису, `cmd/devtools admin` заводит владельца панели | Приложение открывается браузером обычным образом; сервис остаётся без единой ветки для разработчика; строка исключения в сборке образа остаётся одна навсегда | Соседняя задача `dev-run-task` планировала свой пакет `cmd/devadmin` — её придётся переформулировать под подкоманду |
| **Отдельный main-пакет на каждый инструмент** — как было с подставным провайдером | Ничего не надо согласовывать с соседней задачей | Каждый инструмент стоит четырёх мест: строка сборки образа, «Деплой» в устройстве, «Команды» в памятке, `README`. Забытая строка сборки тихо кладёт инструмент разработчика в боевой образ, а инструментов сразу становится два |
| **Контейнер с прокси вместо своего кода** — контур и так стоит на Caddy, docker и так требование к машине разработчика | Каталог точек входа не растёт ни на один пакет | Ещё один контейнер в локальном прогоне и конфиг прокси, который никто не проверяет; отладка «почему не узнан» уходит в чужой контейнер |
| **Ключ конфига, подставляющий имя** | Один процесс вместо двух | В боевом коде появляется ветка, пускающая без всякой проверки; от беды её отделяет только правильность конфига на сервере. Сверх того — **прямо противоречит требованию дельты**: сервис узнаёт пришедшего по заголовку с доверенного адреса и никак иначе, значит цена включает переоткрытие спеки |
**Решено человеком на чекпоинте 2026-08-22:** первый вариант — один пакет
оснастки `cmd/devtools` с подкомандами. Эта работа заводит пакет и подкоманду
`proxy`; подкоманду `admin` заводит задача `dev-run-task`, и её запись надо
переформулировать — она планировала свой отдельный пакет. Строка исключения в
сборке образа остаётся одна навсегда.
## Risks / Trade-offs
- **Весь барьер держится на том, что прокси заголовок перезаписывает, а не
пропускает пришедший.** Запрос от анонима приходит к сервису с адреса прокси,
то есть с доверенного; если прокси не заменит `Remote-User` своим значением, а
оставит присланный, сервис пустит кого угодно под любым именем. Проверить это
репозиторием нечем — правило живёт в `pet-project-server`. → Требование к
контуру называется поимённо в модели угроз, и выкладку запускает человек.
- **Правило Authelia на домен сервиса не заведено.** Тогда прокси заголовка не
ставит вовсе, сервис никого не узнаёт, и все адреса приложения отвечают `401`.
→ Отказ громкий и одинаковый для всех, а перечень доверенных адресов сервис
называет строкой журнала при подъёме.
- **Переименование пользователя в Authelia заводит новую учётную запись**, и
прежние записи остаются у прежней. → Принятая цена, названная в постановке;
записывается в спеку, а не обходится.
- **Два первых обращения одним именем одновременно.** Оба не найдут записи и оба
примутся её заводить. → Уникальный индекс отвергает второго; код на отказ
уникальности повторяет поиск, а не падает.
- **Учётная запись заводится анонимом, если прокси настроен неверно.** Прежде
запись заводилась только успешным входом у провайдера. → Тот же риск, что и
первый, и то же смягчение: заводит её обращение с доверенного адреса, а
доверенный адрес — это прокси.
- **Перечень доверенных адресов, накрывающий весь интернет**, читается как
подсеть и стартует молча. Старт роняется на пустом и на нечитаемом перечне, но
«доверять всякому» остаётся достижимым настройкой — тем самым, что дизайн
отверг решением. → Смягчения барьером нет намеренно: порог «эта подсеть
слишком широка» обходится двумя половинками той же подсети, то есть был бы
барьером на вид. Наблюдаемость вместо барьера: перечень называется строкой
журнала при подъёме.
- **Логин у провайдера переиспользуем**, и новый его владелец получает архив
прежнего. → Записывается ценой в спеку и в модель угроз; не допускать
переиспользования — работа провайдера. Подробно — Р5а.
- **Панель владельца остаётся вне разграничения.** Она и была вне его; барьер
переезжает с правила на литерал пути (обходимого подменой знака) на домен,
закрытый Authelia. → Обход `/%5f/` перестаёт существовать, но канонизация пути
этой работой не делается, и в модели угроз это надо переписать, а не вычеркнуть.
## Migration Plan
Стройка: на сервере данных нет, выкладка пойдёт с чистого листа, переносить
нечего.
1. Новый шаг схемы — колонка ключа `provider_login` с уникальным индексом,
необязательная почта, снятые настройки OAuth2 и **все правила доступа
коллекции пользователей, снятые в пустое**. Применённый
`202608120001_oidc_login` не трогается.
2. Конфиг: секция входа переписывается, образец — вместе с ней.
3. Контур: правило прокси и правило Authelia для домена сервиса заводятся в
`pet-project-server`. Это делает человек, и до этого сервис никого не узнаёт.
Откат: шаг схемы возвращает то, что было до него, кроме открытого создания
записи — его не возвращает и прежний шаг, по той же причине.
## Open Questions
Открытых не осталось: оба вопроса закрыты человеком на чекпоинте 2026-08-22 —
имя ключа настроек (Р4) и способ представиться без прокси (Р8).
@@ -0,0 +1,71 @@
## Why
Сервис ведёт вход своими руками: уводит человека к провайдеру, помнит выданное
состояние у браузера, меняет принесённый код на сессию и потом семь суток верит
выданной куке, ни разу больше не спросив провайдера. Отзыв доступа доходит до
сервиса только истечением этой куки — то есть с задержкой до недели, — а секрет
клиента ради обмена лежит и в конфиге, и в базе.
Обратный прокси между интернетом и сервисом уже спрашивает Authelia на **каждом**
запросе и называет пришедшего заголовком: трём соседним сервисам того же контура
он так и служит. Сервису достаточно этот заголовок прочитать, и весь собственный
протокол входа становится лишним.
## What Changes
- **BREAKING** Кто пришёл, сервис узнаёт из заголовка, поставленного обратным
прокси. Своего входа у него не остаётся вовсе: ни адреса, уводящего к
провайдеру, ни возврата, ни куки, ни выхода.
- **BREAKING** Заголовку верят только с адреса, объявленного доверенным.
Пришедший с любого другого адреса тем же заголовком не узнаётся никак.
- Учётная запись заводится сама, при первом обращении с новым именем, и
находится по нему же при каждом следующем. Имя из заголовка становится ключом
учётной записи и живёт своей колонкой.
- **BREAKING** Секция настроек входа переписывается целиком: адреса провайдера,
идентификатор и секрет клиента уходят, приходит перечень доверенных адресов.
- Секрет клиента исчезает и из настроек хранилища. Вместе с ним снимается изъятие
из инварианта «Секрет не покидает конфиг»: чтение файла базы больше не
равносильно чтению секрета.
- Отзыв доступа перестаёт ждать семи суток: Authelia судит каждый запрос, а
сервис назначенного срока сессии больше не держит вовсе.
- Панель владельца закрывается доменом, а не правилом на литерал пути, и обход
подменой знака в адресе перестаёт существовать. Половина этой работы живёт в
контуре выкладки, вне репозитория.
- Способ представиться на машине без прокси заводится заново: подставной
провайдер уходит вместе с протоколом.
## Capabilities
### New Capabilities
Новых нет: работа меняет то, как узнаётся пришедший, а не заводит новое
поведение.
### Modified Capabilities
- `access`: узнавание пришедшего переезжает с собственного входа у внешнего
провайдера на заголовок доверенного источника. Уходят требования о протоколе
входа, о куке сессии, о сроке её жизни, о выходе, о продлении и о секрете
провайдера в конфиге; приходят требования о доверенном источнике, о заголовке
как имени пришедшего и о заведении учётной записи первым обращением.
Требования о владельце записи, об открытых адресах и о том, как приложение
узнаёт вошедшего, остаются по существу и правятся в формулировках.
## Impact
- Транспорт HTTP: обработчики входа и возврата, слой предъявления куки, слой
запрета продления, корень `/auth` целиком.
- Хранилище: приведение настроек провайдера к конфигу при подъёме, новый шаг
схемы — колонка имени из заголовка, снятие настроек OAuth2 и правила создания
записи. Применённый шаг `202608120001_oidc_login` не переписывается.
- Настройки: секция входа в конфиге и в его образце. Имена ключей — необратимое,
и решение по ним принимает человек.
- Приложение: адрес, которым экран уводил ко входу.
- Инструменты: подставной провайдер OIDC уходит, на его место встаёт способ
представиться без прокси. На него опирается задача `dev-run-task`.
- Документы: модель угроз — периметр, недоверенный вход, разграничение доступа,
сдвиг про секрет в базе; `CLAUDE.md` — изъятие из инварианта о секрете;
устройство и схема хранилища.
- Вне репозитория: правило обратного прокси и правило Authelia для домена
сервиса живут в `pet-project-server`. Этой работой они не проверяются, но
требование к ним она обязана назвать.
@@ -0,0 +1,511 @@
# Ревью изменения `trusted-header-login`
## Сводка
- **Режим прогона:** по графу. **Метка:** `large`.
*Размер и сложность в задании триажу не переданы* — обоснование разметки
воспроизвести нечем, метка взята как объявленная.
- **База диффа:** `origin/master`. Изменение читалось вместе с непрослеженными
файлами (`cmd/devtools/`, `internal/adapter/repo/pocketbase/identity.go`,
`migrations/202608220001_trusted_header_login.go`,
`internal/controller/http/identity.go`, дельта-спеки change).
- **Состояние гейта:** зелёный целиком, кроме шага `dockerfile``hadolint`
даёт `DL3066` на `Dockerfile:94`. Отказ **унаследован** (воспроизведён на
чистом `origin/master`), долг объявлен строкой в `CLAUDE.md`, раздел «Гейт».
Новым красным шагом это изменение гейт не красит.
- **На входе:** 31 пронумерованная находка проходов + 4 пункта «дешевле
переделать до мерджа» от `architecture` + 1 замер без дефекта от `ops`.
**На выходе:** 3 + 4 = 7 в основных секциях, остальное — ниже, ничего не
выброшено молча.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | дельта-спеки change + `openspec/specs/` | разбор | `specs` | закрыта, 8 находок (5–12); в отчёт ушли 2, ещё 4 — ниже |
| autotests | `CLAUDE.md`, «Гейт» | — | `autotests` | закрыта, 4 находки (1–4); в отчёт не ушла ни одна самостоятельно, одна закрывается правкой №3 |
| conventions | `docs/conventions/` | разбор | `code` | закрыта, 8 находок (15–22); в отчёт ушли 2 |
| architecture | `docs/architecture.md` + `docs/passport.md` | доказательство | `architecture` | закрыта, 2 находки + 4 пункта «до мерджа»; в отчёт ушла 1 |
| security | `docs/security.md` | доказательство | `adversary` | закрыта, 6 находок (23–28); в отчёт ушли 3 |
| operations | `docs/architecture.md` «Эксплуатация» + `docs/database.md` | доказательство | `ops` | закрыта, 3 находки + 1 замер без дефекта; в отчёт не ушла ни одна, старшая — в гипотезах |
- **Тем без отчёта нет.** Тем без дома в плане нет.
- **`basics` не запускался** — своих тем проекта нет, все шесть разобраны
именными проходами.
- **Сигнал о заниженной метке:** `review-code` отработал и возражений по метке
**не подал**; `review-basics` не запускался, поднять метку было некому.
Молчание здесь — молчание одного корректора из двух, а не двух из двух.
---
## Блокирует мердж
### Запрос, отвергнутый ограничителем частоты, всё равно заводит учётную запись
- Файл: `internal/controller/http/identity.go:91`, `internal/controller/http/errors.go:219`
- Severity: major (`critical` не ставлю: инвариант `CLAUDE.md` этим не нарушен, а
путь ведёт к мусору в коллекции, а не к порче чужих данных)
- Confidence: high
- Оракул: **свой падающий тест, прогнан на этом прогоне.** Слой узнавания стоит
на `DefaultLoadAuthTokenMiddlewarePriority + 1` = **−1019**, ограничитель
библиотеки — на **1000** (`apis/middlewares_rate_limit.go:16`), то есть
узнавание отрабатывает **до** ограничителя. Прогон: 120 запросов выбирают
бюджет, следующие 50 запросов новыми логинами получают `429`**все 50**, — а
учётных записей в коллекции становится `2 → 52`.
- Последствие: ограничитель частоты не защищает **ничего** из того, что делает
слой узнавания. Каждый отвергнутый запрос — это как минимум чтение из базы
(поиск по логину идёт на каждом запросе по норме `access`), а с новым именем —
ещё и запись. Учётная запись, заведённая мусором, из сервиса не убирается:
спека `access` называет цену прямо — «слить их или убрать нечем». Барьер
«заголовок ставит прокси» этот путь сужает, но не закрывает: `docs/review.md`,
«Недоступно проверке», записывает поведение прокси как непроверяемое отсюда, и
строить на нём защиту базы значит держать один барьер вместо двух.
- Предложение: перенести слой узнавания на
`apis.DefaultRateLimitMiddlewarePriority + 1` (999), а `RequireUser` — на
`+ 2` (−998). Порядок «токен (−1020) → ограничитель (−1000) → узнавание (−999)
→ требование записи (−998) → предел тела (−990)» сохраняется целиком.
- Найдено проходом: `adversary` (25); подтверждено собственным прогоном триажа.
- Действие: **инлайн**
> **Ответ на первый вопрос задания — перестановка проверена запуском.** С
> приоритетами −999/−998 прогнан **весь** `go test ./...`: единственным красным
> остался мой временный тест на находку про общий бюджет (ниже, №4), все
> существующие проверки пакета `internal/controller/http` — включая оба критерия
> приёмки, победу предъявленного токена над заголовком и «проба здоровья
> учётной записи не заводит» — прошли. Заведение записи после `429` при этом
> пропало: `2 → 2`. Предел тела на −990 действительно остаётся после требования
> учётной записи, «отказ неузнанному до чтения тела» сохраняется.
>
> **Одно последствие перестановки задание не называет, и оно настоящее.** Сейчас
> `RequireUser` (1018) отказывает неузнанному **до** ограничителя, и отказы
> бюджета не тратят. После переноса — тратят: замер на этом прогоне, 125
> запросов без заголовка, и следующий запрос узнанного человека получает `429`
> (до перестановки — `200`). Само по себе это правильнее (счёт отказов и есть
> работа ограничителя), но вместе с общим бюджетом (№4) оно даёт анониму,
> дотянувшемуся до контейнера, выключение сервиса для всех. Поэтому №1 и №4
> едут одной правкой, а не порознь.
### Ключ учётной записи правится рукой в панели, и архив уезжает следующему с этим именем
- Файл: `internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go:77-82`,
`internal/adapter/repo/pocketbase/panel.go:33-34`
- Severity: major
- Confidence: high
- Оракул: дословное требование дельта-спеки
`openspec/changes/trusted-header-login/specs/access/spec.md`: «**Ключ учётной
записи MUST не правиться ничем, кроме заведения самим сервисом.** Ни запросом
снаружи, ни рукой в панели». Плюс
`grep -rn "OnRecordUpdateRequest" --include=*.go internal/ cmd/` → единственная
привязка стоит на `migrations.RecordsCollection`, на `users` хука нет. Шаг
схемы закрывает правила API (`UpdateRule = nil`), а панель работает
суперпользователем — это записано комментарием самого шага, строки 48–50.
- Последствие: переписанный в панели `provider_login` отдаёт весь архив прежнего
владельца тому, кто придёт с этим именем следующим. Владелец записи
назначается один раз и не меняется — вернуть архив нечем. Правка своя,
сознательная, но необратимая и **молчаливая**: журнала событий у коллекции
пользователей нет.
- Предложение: развилка ниже.
- Найдено проходом: `specs` (6)
- Действие: **развилка**
> **Вопрос владельцу.** Требование спеки «ключ не правится рукой в панели»
> сегодня не держит ничто: правила API закрыты, но панель ходит
> суперпользователем. Три варианта:
> **(А)** завести хук `OnRecordUpdateRequest(users)`, отвергающий смену
> **непустого** `provider_login` — в проекте уже есть тот же приём для записей
> (`pbrepo.BindPanelRules`), цена — десяток строк и проверка;
> **(Б)** переписать требование, назвав панель доверенной и записав цену
> («ключ правится владельцем сервиса, откатить правку нечем»);
> **(В)** оставить как есть — тогда спека и код расходятся молча, и это
> расхождение переживёт мердж.
### Заведение учётной записи не оставляет в журнале ни строки, а поломка контура пишется на уровне, которого в бою нет
- Файл: `internal/controller/http/identity.go:101-108,128-151`,
`internal/adapter/repo/pocketbase/identity.go:52-87`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Запрос новым логином с доверенного адреса →
ответ `200`, учётная запись заведена, перехваченный журнал — **пустая строка**
(`""`). Дословное требование дельта-спеки `access`: «Исход узнавания MUST
оставлять строку журнала — и когда заголовок пришёл с недоверенного адреса, и
**когда учётная запись заведена**. […] Строка несёт адрес пира и идентификатор
учётной записи». Второй симптом: ветвь «более одного значения `Remote-User`»
(строки 103–106) пишет `Debug`, боевой уровень — `Info`
(`docs/conventions/logging.md`, «Расхождение: уровень зашит константой»).
- Последствие: сервис заводит учётные записи молча — владелец не отличит «никто
не заходил» от «завелось двадцать записей», а по спорной учётной записи не
скажет, когда и с какого адреса она появилась. Хуже второе: `docs/security.md`
называет прокси, добавляющий заголовок вместо замены, **главным** барьером
контура, и половину этой беды сервис закрывает сам — но закрывает **невидимо**:
строка о двух значениях в боевом журнале не появляется вовсе. Поломка контура,
которую сервис поймал, владельцу неотличима от тишины.
- Предложение: `logger.Info` на исходе «учётная запись заведена» — с адресом
пира и идентификатором записи, **без** значения заголовка (инвариант
«Содержимое записи остаётся приватным» и требование спеки «MUST не нести
значения заголовка»); уровень ветви «два значения» поднять до `Warn` — ровно
как у ветви недоверенного пира, по той же причине и с той же ценой.
- Найдено проходами: `specs` (5) и `adversary` (26) — две находки об одной
причине, слиты; **оракула у согласия проходов нет, оракул свой**.
- Действие: **инлайн**
---
## Стоит исправить сейчас
### Бюджет ограничителя частоты общий на весь сервис: один человек выключает сервис остальным
- Файл: `internal/controller/http/rate_limit.go:20-23`, `internal/controller/http/app.go:43-63`,
`internal/config/config.go:91-96`, `cmd/transcriber/main.go:238-246`
- Severity: major
- Confidence: high
- Оракул: **свой падающий тест.** 125 запросов первого человека выбирают бюджет,
**первый** запрос второго человека получает `429`. Причина — в исходниках
библиотеки: `checkRateLimit` берёт ключом `e.RealIP()`
(`apis/middlewares_rate_limit.go`), а `RealIP()` при пустом
`Settings.TrustedProxy.Headers` откатывается к адресу пира
(`core/event_request.go:40-75`). Пир теперь **всегда** обратный прокси.
- Последствие: объявленная сервисом частота опроса выводится из доли бюджета
(`pollBudgetShare = 8`, `PollIntervalMs = 4000`) в расчёте на то, что бюджет
делят немногие. Делят его **все**: восемь одновременных опросов выбирают его
целиком, и девятый человек получает `429` на пустом месте. Отказ шумный, но
причина невидима — по журналу он неотличим от собственной активности.
- **Дефект старше этой задачи** (правило заведено `spa-skeleton` 2026-08-15,
прокси перед сервисом стоял и тогда). Уточнение к формулировке прохода:
`docs/database.md:344` и `app.go:43-49` **не** утверждают обратного — там
прямо написано «бюджет считается по адресу спрашивающего, а не по учётной
записи», и даже назван случай «двое за одним домашним адресом делят его
пополам». Сломалось не правило, а **основание** правила: «адрес
спрашивающего» выродился в один адрес на всех, и число 120/60 выбиралось не
под это.
- Найдено проходами: `architecture` (13) и `adversary` (23); подтверждено
собственным прогоном.
- Действие: **развилка**
> **Ответ на второй вопрос задания: чинить здесь.** Не потому, что дефект
> этой задачи — он не её, — а потому, что правка №1 делает его достижимым для
> **анонима**: после переноса `RequireUser` за ограничитель отказы неузнанному
> начинают тратить общий бюджет (замерено на этом прогоне: 125 анонимных
> запросов → узнанный человек получает `429`). Мерджить №1 без ответа на №4
> значит завести новый путь к отказу сервиса.
>
> **Вопрос владельцу.** Чем считать бюджет ограничителя, когда весь трафик
> приходит с одного адреса:
> **(А)** заполнять `Settings.TrustedProxy.Headers` при подъёме (там же, где
> `ApplyAppRateLimit`) — тогда ключом станет адрес человека из
> `X-Forwarded-For`. Доверие к этому заголовку той же природы, что доверие к
> `Remote-User`, и опирается на тот же перечень адресов; цена — ещё одно
> требование к контуру, которое отсюда не проверить;
> **(Б)** признать бюджет общим на сервис: поднять числа под ожидаемое число
> людей, переписать `docs/database.md` и обоснование `pollBudgetShare`;
> **(В)** отложить в урожай и мерджить №1 с известным ухудшением — тогда
> анонимный поток через прокси выключает приложение всем.
### Негодное значение `Remote-Name` запирает человека в сервисе навсегда пятисоткой
- Файл: `internal/adapter/repo/pocketbase/identity.go:71-87,147-159`,
`internal/controller/http/identity.go:133-147`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Запрос с доверенного адреса, `Remote-User: namebearer`,
`Remote-Name` из 5000 знаков → ответ `500`
`{"error_code":"internal","message":"Внутренняя ошибка сервиса"}`, в журнале
`level=ERROR msg="Failed to resolve account by login header" error="failed to
create user account: name: Must be no more than 255 character(s).."`.
Значение заголовка в ответ и в журнал при этом **не** уехало — инвариант
приватности цел.
- Последствие: приёму подвергается только логин (`AcceptProviderLogin`), а имя и
почта уезжают в колонку как есть. Человек, чьё имя у провайдера длиннее 255
знаков, получает `500` на **каждом** запросе и в сервис не попадёт никогда;
владелец получает `ERROR` на каждый такой запрос. Смежный симптом той же
причины: разбор отказа судит по **наличию колонки** в `validation.Errors`, а не
по причине отказа — негодный адрес почты неотличим от занятого, и запись молча
заводится без почты вместо честного разбора.
- Предложение: привести имя и почту к годным значениям в одном месте с логином —
имя усечь до предела колонки, негодную почту не ставить вовсе. Этой же правкой
закрывается непокрытая ветвь «отказ хранилища → отказ сервиса»
(`identity.go:144-146`), которую `autotests` называет единственным намеренным
исключением из «слой отказа не выдаёт»: у неё появляется достижимый и
проверяемый вход.
- Найдено проходами: `specs` (7, 10), `code` (15, 16), `adversary` (24) — пять
формулировок, две причины, одно место правки; слиты. Согласие пяти проходов
`Confidence` не повышает — оракул свой.
- Действие: **инлайн**
### Образец конфига велит верить всей подсети docker: любой контейнер на хосте входит под любым именем
- Файл: `config.example.toml:69-77`
- Severity: major
- Confidence: medium (путь не построен: второго контейнера в прогоне нет)
- Оракул: положение `docs/security.md:109` дословно — источник заголовков есть
«Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного
адреса**». Образец задаёт этим адресом `172.16.0.0/12`, то есть весь
умолчательный диапазон docker: под него попадает любой контейнер на хосте, а
не только Caddy.
- Последствие: барьер узнавания держится **только** на адресе пира (кук и
токенов сервис не выдаёт). Любой сосед по хосту — а хост держит пет-проекты, а
не один сервис — ставит `Remote-User: <чужой логин>` и получает чужой архив
целиком, молча. Класс риска в `security.md` назван, ширина перечня — нет.
- Предложение: развилка ниже.
- Найдено проходом: `adversary` (27)
- Действие: **развилка**
> **Вопрос владельцу.** Чем сузить перечень доверенных адресов до самого
> прокси:
> **(А)** выделенная сеть compose с фиксированной подсетью под пару
> «прокси ↔ сервис» — правка живёт в `pet-project-server`, здесь остаётся
> значение образца и строка требования к контуру;
> **(Б)** статический адрес прокси в сети docker — то же, но проще и хрупче;
> **(В)** принять `/12` как цену и записать её строкой в `docs/security.md`
> рядом с уже названным классом — тогда это осознанная цена, а не умолчание
> образца.
### Три конвенции проекта описывают снятый механизм, и одна противоречит себе внутри файла
- Файл: `docs/conventions/web-ui.md:107-109`, `docs/conventions/config.md:60-64` против `:99`,
`docs/conventions/logging.md:34-37`
- Severity: minor
- Confidence: high
- Оракул: чтение файлов на этом прогоне. `web-ui.md`: «**Сессия живёт кукой
`transcriber_session`**» — кук сервис больше не ставит вовсе. `config.md`
строка 60–64: «Секретов в этой секции больше нет», строка 99 в том же файле
по-прежнему перечисляет `auth.client_secret` среди секретов. `logging.md`
строки 34–37 описывают расхождение у пакета `cmd/oidcstub`, который этим же
изменением удалён (`git status``D cmd/oidcstub/main.go`).
- Последствие: конвенции — канон проекта, по ним работает следующая задача.
Запись про куку заставит следующего агента искать куку и объяснять её
отсутствие; запись про `client_secret` заставит охранять секрет, которого нет.
Машина этого не ловит: согласованность документов между собой и с кодом судят
агенты, и звать их надо руками (`CLAUDE.md`, «Гейт»).
- Предложение: снять три записи, поправить перечень секретов в `config.md`.
- Найдено проходом: `code` (20)
- Действие: **инлайн**
---
## Гипотезы без доказательства
Понижено и не влезло в потолок; каждая с причиной понижения.
- **Откат бинаря поверх шага схемы `202608220001` обрывает вход** (`ops`, 29;
было `major`). Оракул у прохода настоящий — падающий тест на восстановленном
старом коде во временном worktree, `401` вместо `302`. Понижаю до `minor`
**по стадии и по перекрытию**: (1) стадия — стройка, сервис на сервере
остановлен, данных нет, откатываться не с чего; (2) `docs/architecture.md:142`
уже объявляет откат неработающим начиная с шага `202608140002` — то есть любой
бинарь старше 2026-08-14 и так не поднимается; новое окно отката — только
версии между 14 и 22 августа, ни одна из которых не выкладывалась; (3) старый
бинарь на новом конфиге и вовсе не стартует, если владелец убрал ключи OIDC по
процедуре из «Эксплуатации» — тогда отказ громкий, а не молчаливый.
**Ответ на третий вопрос задания: да, стадия меняет серьёзность.** Правка тем
не менее дешёвая и уместная — строка в `docs/architecture.md`, «Эксплуатация»,
рядом с уже стоящей про `202608140002`: не потому что откат сегодня возможен, а
потому что порог перехода там принято называть прямо.
- **Уникальный индекс по `provider_login` не частичный** (`code`, 17). Оракул
прохода — исходники библиотеки (`core/collection_model.go:648-671`, индекс
собирается без `WHERE`, сосед по коллекции — `WHERE email != ''`). Понижаю:
сервис пустого логина в колонку не кладёт никогда (`AcceptProviderLogin`
отвергает), коллекция пользователей на выкладке пуста, и последствие сводится
к «в панели рукой не завести вторую запись без логина». Промах будущего, не
дефект сегодня.
- **Предел логина считается в байтах, колонка — в знаках** (`specs`, 8;
`code`, 18). Проверено чтением: `len(login) > 255` в
`identity.go:174` против `Max: 255` у текстовой колонки. Понижаю: перекос
только в сторону строгости, кириллический логин длиннее 127 знаков у Authelia
не встречается, следа `Debug` хватает. Пути нет — гипотеза.
- **Склейка значений заголовка запятой обходит защиту «два значения»**
(`adversary`, 28). Пути нет и построить его отсюда нечем: `net/http` заголовки
запятой не сливает, а прокси, который сливает, — ровно тот класс, который
`docs/review.md` относит к «не проверит ни один проход». Последствие при этом
было бы неприятным и тихим (человек попадает в **новую** пустую учётную
запись вместо своей), поэтому строка оставлена, а не выброшена.
- **Откат шага схемы не восстанавливает `OAuth2.Enabled` и минирует записи без
почты** (`specs`, 9; `ops`, 30). Оракул у `ops` есть — прогон на временной
базе, `email: cannot be blank`. Понижаю по тому же основанию, что и №29: `down`
на сервере не зовётся, а применённый шаг не переписывается по инварианту.
- **Отказ хранилища при узнавании не покрыт ничем** (`autotests`, 2). Не
выведена отдельной строкой: правка про негодное имя даёт этой ветви
достижимый вход, и тест на неё пишется той же правкой. Если владелец выберет
не чинить негодное имя — эта строка возвращается самостоятельной находкой.
- **Непокрытые ветви `retryAfterConflict` и `web/src/api.ts`** (`autotests`, 1,
3, 12). Понижаю по записанному решению проекта: `CLAUDE.md`, «Гейт» —
«покрытие изменённых строк не считается ничем». Своего `api.test.ts` у нового
`web/src/api.ts` действительно нет (проверено `ls web/src/`), и это стоит
завести — но это задача, а не блокирующая находка.
---
## Promote candidates
Претензии на правило, а не на этот код.
- **Поле `peer` заведено мимо словаря имён журнала** (`code`, 21).
`docs/conventions/logging.md:99` требует точечной иерархии для системных
доменов (`http.*`, `ext.*`, `webapp.*`), а в файле уже стоит *Расхождение* с
перечнем самозаведённых имён. Кандидат в конвенцию: внести `http.peer_addr` в
таблицу полей — тогда следующий агент возьмёт имя из словаря, а не сочинит
третье.
- **Третий способ вывода в репозитории** (`code`, 22). `cmd/devtools` печатает
через stdlib `log`, тогда как `logging.md` знает два. Записанной конвенции об
оснастке у проекта нет — значит это кандидат в правило, а не нарушение.
- **Единственный дом имён заголовков `Remote-*`** (`architecture`, 14).
`cmd/devtools/main.go:92-97` пишет `"Remote-User"`, `"Remote-Name"`,
`"Remote-Email"` своими литералами, тогда как нормативные константы лежат в
`internal/controller/http/identity.go:23-27`. Правка на две строки, но правило
общее — «нормативное имя объявляется один раз» — и его стоит записать, а не
чинить точечно. Смежный вопрос `docs/review.md`, «Вопросы по темам»: не завёл
ли инструмент разработчика второй дом тому, что уже есть в проверках.
- **Второй словарь текстов на клиенте** (`code`, 19). Половина находки —
ложноположительная: `docs/conventions/web-ui.md:118` **сам** разрешает
клиентский текст «связи нет» состоянием, а не ошибкой. Настоящий остаток —
расхождение текстов: сервер на `401` отвечает «Требуется вход» про вход,
которого у сервиса больше нет, а экран показывает свой текст поверх. Кандидат
в правило: конвенция сегодня не различает «текст под код ответа» и «текст под
состояние клиента», и на этом различии находка и разошлась.
---
## Урожай
Настоящее, но не для этого мерджа.
- **Бюджет ограничителя частоты по человеку, а не по адресу** — если владелец
выберет вариант (В) на развилке №4.
- **Секция конфига называется `[auth]` при отсутствии всякого входа**
(`architecture`). Имя ключа конфига — из перечня необратимого в `CLAUDE.md`
(«спрашивается у человека всегда»), но **сегодня стадия стройки**: на сервере
ничего нет, и переименование стоит ноль. После первой выкладки — уже решение
человека. Если переименовывать, то сейчас.
- **Колонка почты без единого читателя** (`architecture`). Почта пишется при
заведении и не читается ничем. Не дефект: спека объявляет её необязательной и
берёт «при заведении». Но колонка без читателя — долг, который стоит либо
оправдать спекой, либо снять.
- **Корень `/auth` перестал быть зарезервированным** (`architecture`). Проверено:
упоминаний `/auth` в `app.go` больше нет. Освободившееся адресное пространство
стоит либо занять, либо назвать свободным в спеке `webapp`.
- **Свой тест у `web/src/api.ts`** — файл новый, разбор отказов в нём есть,
проверки нет.
- **Строка про порог отката в `docs/architecture.md`, «Эксплуатация»** — см.
гипотезу №29.
---
## Границы покрытия
### План: темы, дома, глубины
Воспроизведён таблицей в сводке выше целиком. Тем без дома в плане нет, тем без
отчёта нет.
### Что запускалось
- Режим: по графу, метка `large`. Запущены `specs`, `autotests`, `code`,
`architecture`, `adversary`, `ops`.
- **`basics` не запускался** — своих тем проекта у него на этом прогоне нет, все
шесть тем разобраны именными проходами. Следствие: сигнал о заниженной метке
мог подать только `review-code`, и он его не подал.
### Чего не хватило триажу, и это находки о прогоне
- **Блоков «Coverage of this pass» в сырых выводах нет ни у одного прохода.**
Контракт находок требует их у каждого, и сводить мне было нечего: строку «что
этот проход не мог проверить в принципе» я по каждому проходу воспроизвести не
могу. Ниже — только то, что выводится из плана и из документов проекта.
- **Сработавшие потолки ни один проход не назвал.** Сколько находок проход
показал, каков был его потолок и что осталось за срезом — не сообщено никем.
Значит утверждать «проход показал всё, что нашёл» нельзя ни про один из шести.
- **Размер и сложность изменения триажу не переданы** — только метка.
Обоснование разметки в отчёте воспроизвести нечем.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке». **Два списка, и они не сливаются.**
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: **поведение настоящей Authelia и правило обратного прокси на домен
сервиса.** С 2026-08-22 от прокси зависит **весь** барьер: он обязан заголовки
`Remote-*` перезаписывать, а не пропускать пришедшие. Правило живёт в
`pet-project-server`, и отсюда его не проверить. Прямо на этом висят находки
№1 (насколько узок путь к заведению мусорных записей), №6 (ширина перечня
доверенных адресов) и гипотеза про склейку запятой;
- `security`: поведение браузера с куками — своих кук сервис больше не ставит,
класс сузился до кук панели хранилища.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe` — решение и цена в
`ADR-2026-08-11-stub-adapters-in-tests.md`;
- **работа сервиса с настоящими внешними собеседниками** в остатке: за настоящие
SpeechKit и Object Storage живой прогон не отвечает — ключи выдуманные,
распознавание подменяется в коде. Вход при этом с 2026-08-22 живой прогон
проверяет целиком (`cmd/devtools proxy`), и это сдвиг в другую сторону.
Плюс общее, что не проверяет ни один прогон: история инцидентов, поведение под
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
текущее поведение и вопрос «а нужна ли эта функциональность вообще».
### Каких документов проекта не хватило
- `docs/review.md`, «Типовые ложноположительные» — **есть и не пуст**, отсев по
нему выполнен. Одна находка (`code`, 19) отсеяна наполовину по общим
критериям, а не по нему: разрешение клиентского текста для состояния «связи
нет» стоит в `docs/conventions/web-ui.md`, а не в разделе
ложноположительных.
- `CLAUDE.md`, «Инварианты» — есть; ни одна находка этого прогона до `critical`
по этому основанию не поднята, потому что ни одна инвариант не нарушает:
проверено поимённо для приватности содержимого (значение заголовка в журнал и
в ответ не уходит — замерено), для секрета в конфиге (секретов в секции
`[auth]` больше нет) и для запрета переписывать применённый шаг схемы.
- **Прочих пробелов в документах проходы не назвали** — но назвать их было
некому: блоков границ у проходов нет (см. выше). Отсутствие строки здесь не
означает, что документов хватило.
### Четыре строки, которых не принёс ни один проход
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
его не открывает. Изменение заводит `ADR-2026-08-22-login-by-trusted-header.md`,
и сверку его с остальными записанными решениями — включая те, что это
изменение помечает (`ADR-2026-08-12-oidc-exchange-via-own-route`,
`ADR-2026-08-12-session-without-refresh`), — конвейер ревью не делал.
Расхождение изменения с записанным решением ловит скилл
`av-dev:doc-healthcheck`, и звать его надо руками.
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
процессный. Всякое число в этом отчёте снято на этом прогоне: приоритеты
слоёв взяты из исходников библиотеки в `GOMODCACHE`, счёт учётных записей и
коды ответов — из прогонов, приложенных к находкам.
3. **Поимённая сверка с руководствами по стилю Go и Vue не задавалась ни одним
проходом.** Различение «идиоматично против распространено» на этом прогоне не
спрашивал никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру.
«Не знаю, чего не знаю» про схему узнавания по доверенному заголовку никто не
доставал.
Пятой строки — про метку `small` — на этом прогоне нет: метка `large`, дома тем
`security`, `operations` и `architecture` открывались.
### Что делал сам триаж
- Прочитаны: дифф изменения, `CLAUDE.md`, `docs/review.md` целиком,
`docs/security.md`, `docs/architecture.md` «Эксплуатация», `docs/database.md`
«Настройки с числовым значением», три конвенции, дельта-спека `access`,
исходники `pocketbase@v0.39.10` (`apis/middlewares*.go`,
`core/event_request.go`).
- Запущено во временном файле проверок пакета `internal/controller/http`
(удалён после прогона, дерево восстановлено, `go build ./...` и
`go test ./internal/controller/http ./internal/adapter/repo/pocketbase`
зелёные):
- заведение учётных записей запросами, ответившими `429` (`2 → 52`);
- общий бюджет ограничителя между двумя людьми;
- `500` и `ERROR` на длинном `Remote-Name`;
- пустой журнал на заведении учётной записи;
- расход бюджета анонимными отказами до и после перестановки приоритетов
(`200``429`).
- Перестановка приоритетов на −999/−998 применена **временно** и откачена;
на ней прогнан весь `go test ./...`. Код изменения не правился.
@@ -0,0 +1,674 @@
## ADDED Requirements
### 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** журнал подъёма называет доверенные адреса
## MODIFIED 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`
## REMOVED Requirements
### Requirement: Вход через внешнего провайдера
**Reason**: Сервис больше не ведёт входа. Собственный адрес, уводящий к
провайдеру, адрес возврата, сверка состояния, проверочный код PKCE и обмен кода
средствами хранилища убраны целиком: кто пришёл, называет обратный прокси,
сходивший к провайдеру на **каждом** запросе.
**Migration**: Узнавание нормирует требование «Пришедшего называет доверенный
источник». Правило провайдера переезжает с клиента OIDC на домен сервиса и живёт
в настройках выкладки.
### Requirement: Сессия предъявляется кукой
**Reason**: Сессии у сервиса нет вовсе. Кука `transcriber_session`, кука
состояния входа `transcriber_login` и слой, перекладывающий значение куки в
заголовок хранилища, убраны: узнавание идёт заголовком на каждом запросе, и
значения, переживающего запрос, сервис не выдаёт.
**Migration**: Требование «Пришедшего называет доверенный источник» — там же
записан запрет выдавать браузеру что-либо, переживающее запрос.
### Requirement: Сессия переживает перезапуск сервиса
**Reason**: Переживать перезапуск нечему. Узнавание не опирается ни на значение,
выданное однажды, ни на секрет подписи: заголовок приходит с каждым запросом.
**Migration**: Не требуется — свойство выполняется по построению.
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
**Reason**: Срока нет, потому что нет самой сессии. Он существовал ровно затем,
чтобы отзыв доступа у провайдера когда-нибудь дошёл до сервиса; теперь провайдер
судит каждый запрос, и отзыв действует со следующего. Запрет продления и способ
закрыть чужие сессии немедленно теряют предмет вместе со сроком.
**Migration**: Отзыв доступа нормирует требование «Кого пускать, решает
провайдер», сценарий про следующий запрос.
### Requirement: Выход прекращает доступ
**Reason**: Выхода у сервиса нет: обесценивать нечего и куку убирать неоткуда.
Выходят у провайдера, и со следующего запроса прокси заголовка уже не поставит.
**Migration**: Требование «Кого пускать, решает провайдер».
### Requirement: Секрет провайдера живёт в конфиге
**Reason**: Секрета клиента у сервиса больше нет: обменивать код не на что.
Вместе с ним из конфига уходят адреса провайдера и идентификатор клиента, а из
настроек коллекции пользователей — приведение их к конфигу при подъёме. Отсюда же
снимается изъятие из инварианта «Секрет не покидает конфиг»: чтение файла базы
больше не равносильно чтению секрета клиента.
**Migration**: Настройку узнавания нормирует требование «Доверенный источник
объявлен настройкой»; проверка целостности настройки на старте сохраняется там.
@@ -0,0 +1,100 @@
## MODIFIED Requirements
### Requirement: Отказ называет причину, а не место
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
разнице кодов перебирается список заведённых записей;
- узнанный предъявитель без учётной записи пользователя — `403`;
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
отвечать чужая и ничья запись;
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
негодный размер страницы;
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у
записи ещё нет;
- отказ хранилища и всякая неназванная причина — `500`.
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места
нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую
базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как
«моя запись пропала», а второе не говорит ему ничего.
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
все три `400`, — а приложению надо решать, предлагать ли повтор и что показать
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
задача экрана переписала бы контракт, согласованный здесь один раз.
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и
выбранные кодом они стали бы контрактом молча:
- поля тела: `error_code` и `message`;
- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`,
`bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`.
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на
адресах приложения две, а самый частый отказ у человека на мобильной сети —
«запись больше потолка» — приходит телом библиотеки, без кода и без предела
числом.
Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
журнале аварию там, где её нет.
Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
остаётся в журнале владельца сервиса.
#### Scenario: Сбой хранилища виден как сбой
- **GIVEN** хранилище отвечает отказом драйвера на чтение записи
- **WHEN** владелец спрашивает свою запись
- **THEN** ответ имеет код `500`
- **AND** тела записи в ответе нет
#### Scenario: Негодная запись видна как негодная
- **GIVEN** источник метаданных не может прочитать присланную запись
- **WHEN** отправитель шлёт её приёмом
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** причина отказа в тело ответа не попадает
#### Scenario: Отказ по пустому владельцу
- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет
- **WHEN** он шлёт запись приёмом
- **THEN** ответ имеет код `403`
#### Scenario: Форма тела одна на всех ветвях отказа
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
отсутствию учётной записи и по сбою хранилища
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
полями
- **AND** код отказа принадлежит закрытому перечню
- **AND** ни одно из них не содержит сырого текста ошибки
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
- **GIVEN** заведена запись
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
неизвестному идентификатору
- **THEN** оба ответа имеют код `401` и одно тело
#### Scenario: Запись сверх потолка размера
- **GIVEN** отправитель узнан
- **WHEN** он шлёт запись длиннее потолка размера
- **THEN** ответ имеет код `413`, а тело несёт предел числом
- **AND** ни файла, ни аудиозаписи не заводится
@@ -0,0 +1,111 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос от неузнанного MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`.
Приём стоит тем же адресом, что и список записей, и отличается от него только
методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя
запись он и создаёт.
Ответ MUST нести **список** заведённых записей и место под признак повторного
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом
нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки —
переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним:
меняется форма ответа, не число файлов.
Элемент списка MUST нести те же поля, что и карточка записи, плюс признак
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
Состав карточки нормирует capability `archive`.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес
опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка
публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет
достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе.
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
и код с телом такого отказа нормирует capability `archive` наравне с прочими
ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание —
самый частый отказ у человека на мобильной сети — прежде не был нормирован
ничем и уходил телом ограничителя тела, мимо единой формы.
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, узнанный без учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ
неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно
такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него
нет, и владельцем записи он стать не может.
Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
#### Scenario: Узнанный без учётной записи пользователя
- **GIVEN** предъявлен собственный токен владельца панели
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Пришедший не узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель узнан
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
@@ -0,0 +1,143 @@
## MODIFIED Requirements
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
Правило MUST пускать только владельца файла: незаданное означает «только владелец
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
отдавало чужое аудио тому, кто знает идентификатор записи.
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
токена: отказ наступает там, и требовать его от выдачи значит требовать
механизма, которого нет.
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
хранилища, а не недосмотр.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **AND** забирающий узнан и взял токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Неузнанному файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают неузнанным
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Токен файла выдаётся узнанному по заголовку
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он просит у хранилища токен файла
- **THEN** токен выдаётся
#### Scenario: Конвейер читает файл без узнавания
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** хранилище его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
@@ -0,0 +1,126 @@
## MODIFIED Requirements
### Requirement: Неизвестный путь вне корней открывает приложение
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, —
отдельными адресами стоят `/health` и `/metrics`.
Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и
адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем
же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя
за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается
от любого другого свободного имени, а второй перечень «когда-то занятых корней»
разошёлся бы с первым молча.
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
`/api` не достался бы никому и уехал бы разметкой.
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
контракта остаётся отказом контракта и уходит той формой, которой этот корень
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
приняла бы её за ответ.
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
отвечать `405`.
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
- **GIVEN** приложение открыто на своём маршруте
- **WHEN** браузер спрашивает этот путь заново
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Голый корень разметкой не подменяется
- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без
косой черты
- **THEN** тело ответа — не разметка приложения
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
отделён от него косой чертой
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес входа открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем входа
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** он спрашивает неизвестный путь под корнем приложения
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
- **THEN** тело ответа — не разметка приложения
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
- **THEN** ответ имеет код `404`
- **AND** тело ответа — не разметка приложения
### Requirement: Открытое приложение показывает вошедшего
Приложение SHALL спрашивать сервис, кто пришёл, и показывать его имя. Отказ
`401` MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
только мимо него.
Всякий **иной** отказ и сорванный запрос MUST показываться строкой о неудаче.
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
перехода.
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
адрес почты: его в ответе нет по норме `access`.
#### Scenario: Узнанный виден
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
- **WHEN** он открывает приложение
- **THEN** приложение показывает его имя
#### Scenario: Неузнанному показывают, что его не узнали
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о том, что его не узнали
- **AND** никуда его не уводит
#### Scenario: Отказ сервиса от неузнавания отличается
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
неузнаванием
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о неудаче
- **AND** эта строка не та, которой оно сообщает о неузнавании
@@ -0,0 +1,206 @@
## Критерии приёмки
Дословно из записи задачи `trusted-header-login`. Файл задачи закрытие удалит —
критерии обязаны его пережить.
1. Обращение к адресу приложения с заголовком от доверенного источника идёт от
имени учётной записи, заведённой при первом таком обращении, а повторное с тем
же значением попадает в ту же запись. Оракул — тест обработчика: два запроса
подряд, в хранилище одна запись пользователя.
2. Тот же заголовок с недоверенного адреса даёт `401`, а не вход под названным
именем. Оракул — тест: запрос с адресом источника вне перечня доверенных.
3. Собственные адреса входа хранилища сессии не выдают и учётную запись не
меняют. Оракул — тест по перечню адресов под `/api/collections/users/`: каждый
отвечает отказом.
4. Механики OIDC в дереве не осталось: корня `/auth`, кук входа,
`ApplyProviderSettings`, `cmd/oidcstub` и секрета клиента в конфиге. Оракул —
поиск по этим именам плюс зелёный `task gate`.
5. Разграничение записей по владельцу работает как прежде: чужая запись
неотличима от несуществующей. Оракул — существующие тесты владельца остаются
зелёными.
## Рубрика ревью дизайна
Двенадцать свойств, по которым судится узел этого рода — слой узнавания
предъявителя плюс заведение учётной записи первым обращением. Порождены до
чтения дизайна. Приёмка идёт по одному списку: эти пункты наравне с критериями
постановки выше.
1. Доверие ограничено источником, и источник берётся у соединения, а не у
пересылаемого значения; настройка проверяется на старте, небезопасного
умолчания нет.
2. Заголовок узнаётся однозначно: более одного значения не даёт «первое
попавшееся».
3. Вырожденное значение — пустое, пробельное, сверх предела длины, с
управляющими знаками — не узнаёт никого и не заводит ничего.
4. Ключ учётной записи стабилен, и цена его нестабильности названа в обе стороны
— и смена, и переиспользование. Выборка по ключу имеет свой индекс.
5. Заведение первым обращением идемпотентно и устойчиво к конкуренции; отказ
уникальности не по ключевой колонке имеет назначенный исход.
6. Результат остаётся функцией уже произошедшего: ключевая колонка неизменяема
после заведения.
7. Второго способа стать этим человеком нет, а при двух предъявленных удостоверениях
победитель назначен нормой, а не порядком слоёв.
8. Закрытие поверхности не отменяет законного пути заведения записи самим
сервисом.
9. Значение заголовка — недоверенный вход на всём пути: не в журнал, не в ответ,
не в метку метрики, и в хранилище параметром, а не подстановкой в фильтр.
10. Охват слоя назван, перечень открытых адресов закрыт, посторонний заголовок
ответа открытых адресов не меняет.
11. Срок узнавания назван: всё, что переживает запрос, либо отсутствует, либо
имеет назначенный срок, и этот срок и есть задержка отзыва.
12. Исход узнавания наблюдаем владельцем и неразличим для отправителя.
## 1. Схема хранилища
- [x] 1.1 Новый шаг схемы `202608220001_trusted_header_login.go`: колонка
`provider_login` в коллекции `users` с уникальным индексом, почта
переведена в необязательную, `OAuth2.Enabled = false` и
`OAuth2.Providers = nil`. Применённый `202608120001_oidc_login` не
трогается.
- [x] 1.2 Тот же шаг снимает **все** правила доступа коллекции `users` в пустое —
`ListRule`, `ViewRule`, `CreateRule`, `UpdateRule`, `DeleteRule`. Сегодня
четыре из них библиотечные (`id = @request.auth.id`), и правка своей записи
открыта: это путь захвата чужого имени через ключевую колонку.
- [x] 1.3 Шаг зарегистрирован в `migrations.go`, имя ключевой колонки объявлено
константой рядом с именами коллекций.
- [x] 1.4 `schema_test.go` сторожит `users` наравне с шестью коллекциями,
которые он уже проверяет: все пять правил пусты.
- [x] 1.5 Откат шага возвращает то, что было до него, кроме открытого создания
записи и открытой правки, и не падает на проверке коллекции.
- [x] 1.6 `docs/database.md` описывает новую колонку, снятые настройки и снятые
правила доступа.
## 2. Настройка
- [x] 2.1 Секция входа конфига переписана: адреса провайдера, идентификатор и
секрет клиента, адрес возврата и признак защищённой куки убраны, перечень
доверенных адресов заведён: секция `[auth]` остаётся под своим именем, в
ней один ключ `trusted_proxies` (решение человека 2026-08-22).
- [x] 2.2 Проверка настройки на старте: пустой перечень и нечитаемая строка
роняют старт с именем ключа.
- [x] 2.3 `config.example.toml` переписан вместе с секцией, раздел про локальный
вход заменён на подставной прокси.
## 3. Узнавание пришедшего
- [x] 3.1 Слой узнавания в транспорте HTTP: читает заголовок, судит адрес пира по
перечню, ставит учётную запись предъявителя только когда её ещё нет.
Вешается корневым, но действует на объявленной области — корень приложения
плюс адрес выдачи файлового токена; область выводится из перечня адресного
пространства, а не пишется вторым списком.
- [x] 3.2 Приём значения заголовка: пустое и пробельное не узнают никого, более
одного значения не узнаёт никого, предел длины и отказ на управляющие
знаки, сравнение точное. Значение уходит в хранилище параметром, а не
подстановкой в текст фильтра.
- [x] 3.3 Поиск и заведение учётной записи — **методом пакета хранилища**, а не
куском в транспорте: у правила один дом, и `api-tokens` возьмёт его же.
Имя и почта берутся только при заведении, найденная запись не
переписывается; отказ уникальности по ключевой колонке ведёт к повторному
поиску, по любой другой — к заведению записи без почты.
- [x] 3.4 Отказ хранилища при узнавании кончается отказом сервиса, а не
молчаливым проходом неузнанным.
- [x] 3.5 Журнал: исходы «заголовок с недоверенного адреса» и «учётная запись
заведена» — с адресом пира и идентификатором записи, без значения
заголовка; перечень доверенных адресов называется строкой при подъёме.
- [x] 3.6 `ApplyProviderSettings`, `ProviderSettings` и `ProviderName` убраны из
пакета хранилища; `SessionDuration` убран вместе со сроком сессии.
## 4. Снос механики OIDC
- [x] 4.1 `internal/controller/http/auth.go` удалён целиком вместе с корнем
`/auth` в перечне адресного пространства.
- [x] 4.2 `SessionFromCookie` и `BlockSessionRefresh` удалены; места их привязки
переписаны на новый слой.
- [x] 4.3 `cmd/oidcstub` удалён.
- [x] 4.4 Приложение: адрес, которым экран уводил ко входу, убран; неузнавание
показывается строкой и отличается от прочей неудачи. Юнит-тесты приложения
обновлены.
## 5. Способ представиться без прокси
- [x] 5.1 Заведён `cmd/devtools` с подкомандой `proxy`: слушает свой порт,
ставит заголовок, переправляет запрос сервису. Подкоманду `admin` заводит
задача `dev-run-task`. В образ пакет не едет: строка сборки `Dockerfile`
называет точку входа поимённо.
- [x] 5.2 `CLAUDE.md`, раздел «Команды» и раздел «Запреты» — про локальный вход
без прокси; `README.md`, если он про это говорит.
## 6. Проверки
- [x] 6.1 Тест: первое обращение с доверенного адреса заводит учётную запись,
второе с тем же значением попадает в ту же — в хранилище одна запись
(критерий 1).
- [x] 6.2 Тест: тот же заголовок с недоверенного адреса даёт `401`, и учётной
записи не появляется (критерий 2).
- [x] 6.3 Тест по перечню собственных адресов входа хранилища под
`/api/collections/users/`: каждый отвечает отказом и учётной записи не
меняет (критерий 3).
- [x] 6.4 Тест: годный собственный токен побеждает заголовок, а протухший
узнаванию не мешает.
- [x] 6.4a Тест: узнанный не правит свою запись в коллекции пользователей
запросом к хранилищу и не перечисляет коллекцию — путь захвата чужого имени
закрыт.
- [x] 6.4b Тест: пустой заголовок, два значения одного заголовка и значение
сверх предела длины не узнают никого и записи не заводят.
- [x] 6.4c Тест: два одновременных первых обращения одним значением дают одну
запись.
- [x] 6.4d Тест: занятый адрес почты не мешает завести запись — она заводится
без почты.
- [x] 6.4e Тест: запрос с заголовком на `GET /health` учётной записи не заводит.
- [x] 6.4f Тест: прежние адреса под корнем `/auth` отдают разметку приложения.
- [x] 6.5 Тест: значение заголовка не попадает в журнал.
- [x] 6.6 Тест: ответ на успешный запрос не ставит браузеру куки.
- [x] 6.7 Тест: токен файла выдаётся узнанному по заголовку, и путь к файлу
записи проходит целиком.
- [x] 6.8 Существующие тесты владельца и разграничения переписаны на новый способ
представиться и остаются зелёными (критерий 5).
- [x] 6.9 Тесты входа, обмена кода, куки, продления и выхода удалены вместе с
предметом.
## 7. Документы и закрытие
- [x] 7.1 `docs/security.md`: периметр, недоверенный вход, разграничение доступа;
четвёртый сдвиг про секрет в базе снят; требование к контуру — прокси
**ставит** заголовок, а не пропускает пришедший — названо поимённо; там же
цена переиспользования логина у провайдера.
- [x] 7.2 `CLAUDE.md`: изъятие из инварианта о секрете снято.
- [x] 7.3 `docs/architecture.md` приведён в соответствие; в «Единые точки
проекта» добавлена строка про дом правила узнавания предъявителя.
- [x] 7.3a `docs/passport.md`: строки про сессию OIDC у потребителей и в границе
«Управление учётными записями» — учётную запись сервис не заводит по своей
воле, а зеркалит имя, названное провайдером.
- [x] 7.4 Поиск по `oidc`, `transcriber_session`, `transcriber_login`,
`ApplyProviderSettings`, `/auth/` в дереве не находит живого кода
(критерий 4).
- [x] 7.5 `task gate` зелёный, кроме унаследованного `hadolint DL3066` — отказ
воспроизводится на чистом `origin/master`, объявлен долгом в `CLAUDE.md`
(критерий 4).
## 8. Отработка ревью кода
- [x] 8.1 Слой узнавания переехал за ограничитель частоты: отвергнутый запрос
больше не заводит учётной записи.
- [x] 8.2 Заведение учётной записи пишется в журнал; два значения заголовка —
предупреждением, а не отладочным уровнем.
- [x] 8.3 Имя и почта принимаются, а не кладутся как есть: длинное имя больше не
запирает человека вечным отказом сервиса.
- [x] 8.4 Отказ уникальности судится по коду, а не по имени колонки: негодная
почта отличима от занятой.
- [x] 8.5 Предел логина считается в знаках, а не в байтах.
- [x] 8.6 Уникальный индекс по ключу сделан частичным — как соседний индекс
почты; подъём на непустой базе больше не роняет накатку.
- [x] 8.7 Ключ учётной записи закрыт от правки рукой в панели модельным хуком.
- [x] 8.8 Хранилищу назван заголовок адреса спрашивающего: бюджет ограничителя
перестал быть общим на весь сервис.
- [x] 8.9 Приложение показывает текст сервера, своего словаря под коды ответа не
заводит; текст `401` больше не зовёт ко входу, которого нет.
- [x] 8.10 Оснастка берёт имена заголовков константами транспорта.
- [x] 8.11 Три записи конвенций приведены к действительности; словарь полей
журнала пополнен, изъятие про вывод оснастки записано.
- [x] 8.12 Порог отката образа назван в «Эксплуатации»; требование к контуру про
`X-Forwarded-For` и цена ширины перечня — в модели угроз.
- [x] 8.13 Образец конфига сужен до адреса прокси.
- [x] 7.6 Поведенческая проверка: сервис поднят локально, приложение открывается,
учётная запись заводится первым обращением, запрос без заголовка получает
отказ.
+477 -321
View File
@@ -2,11 +2,16 @@
## Purpose
Кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
открытыми.
Кто пришёл в сервис и пускают ли его дальше: узнавание по заголовку доверенного
источника, заведение учётной записи первым обращением и то, какие адреса
остаются открытыми неузнанному.
Здесь же разграничение записей по владельцу: с 2026-08-14 сессия отвечает не
Своего входа у сервиса нет. Собственный протокол OIDC — с адресом, уводящим к
провайдеру, возвратом, кукой сессии и её сроком — жил здесь с 2026-08-12 по
2026-08-22 и убран задачей `trusted-header-login`: пришедшего называет обратный
прокси, сходивший к провайдеру, и делает это на каждом запросе.
Здесь же разграничение записей по владельцу: с 2026-08-14 узнавание отвечает не
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
@@ -15,360 +20,183 @@ OIDC, чем предъявляется сессия, что её прекращ
вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход
убран вместе с этим исключением.
## Requirements
### Requirement: Вход через внешнего провайдера
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
MUST не заводить: учётные записи держит провайдер, и это граница домена из
паспорта.
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
выдавал. Без этой сверки вход принимает чужой код.
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
средствами хранилища его требует.
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
Уборка носителя MUST происходить до записи ответа. Отложенная не работает вовсе:
заголовки фиксируются в момент, когда ответ начинают писать, и позднейшая правка
до браузера не доезжает.
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
держит обработчик возврата открытым неограниченно долго, а «провайдер медленный»
становится неотличим от «провайдер отказал».
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
`GET` его срабатывание уносится переходом по чужой ссылке.
#### Scenario: Человек входит впервые
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход и возвращается с кодом провайдера
- **THEN** учётная запись заводится, а сессия открывается
- **AND** дальнейший запрос к API от этой сессии проходит
#### Scenario: Признаки носителя состояния
- **WHEN** сервис уводит человека к провайдеру
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
что и кука сессии
#### Scenario: Возврат нельзя переиграть
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
- **THEN** сессия не открывается, а ответ несёт отказ
#### Scenario: Возврат с чужим состоянием
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
выдавал
- **THEN** сессия не открывается, а ответ несёт отказ
- **AND** учётная запись не заводится
#### Scenario: Провайдер отказал
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
- **THEN** сессия не открывается, а ответ несёт отказ
### Requirement: Иных способов открыть сессию нет
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
запись и получить сессию. Собственное создание записи в коллекции пользователей,
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
Сервис SHALL оставить собственные адреса входа хранилища неработающими: ни один
из них MUST не давать доступа и MUST не менять учётной записи. Собственное
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть
выключены настройкой коллекции.
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
**Закрывается не только вход, но и правка.** Перечисление, чтение, создание,
правка и удаление записи коллекции пользователей MUST быть закрыты правилами
доступа — то есть оставлены пустыми, что у хранилища означает «только владелец
панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих
пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с
кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть
защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей
записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил,
панель работает суперпользователем, своих экранов профиля сервис не заводит —
закрытие не стоит ничего.
Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то
нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования закрытие приёма обходится
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
включённым входом по паролю, и без этого требования узнавание по заголовку
обходится двумя запросами: завести себе запись, войти по паролю, предъявить
полученное.
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
настоящему входу с этим адресом.
Отдельная цена у открытого создания записи — захват учётной записи: запись,
заведённая посторонним под чужим именем, досталась бы первому же настоящему
обращению с этим именем.
Закрытие MUST не отменять заведения записи самим входом: запись при первом входе
заводит внутренний запрос обмена, и правило, отвергающее его наравне с
посторонним, оставляет сервис без единого способа войти.
Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при
первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему
не судья.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
- **WHEN** запрос снаружи создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Вход у провайдера запись заводит
#### Scenario: Обращение с заголовком запись заводит
- **GIVEN** учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход у провайдера
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит с доверенного адреса
- **THEN** учётная запись появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а сессия не открывается
- **THEN** ответ несёт отказ, а доступа не открывается
#### Scenario: Обмен кода у провайдера недоступен
- **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции
пользователей
- **THEN** ответ несёт отказ, а доступа не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
### Requirement: Сессия предъявляется кукой
#### Scenario: Правка учётной записи снаружи закрыта
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
(`SameSite=Lax` или строже).
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу
- **THEN** ответ несёт отказ, а запись остаётся прежней
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
#### Scenario: Перечисление учётных записей закрыто
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
Сервис MUST перекладывать значение куки в этот заголовок **только когда
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена **адресами приложения** — теми, что
живут под его собственным корнем. Собственная поверхность хранилища под него не
подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не
шлёт, и расширение слоя на всё сняло бы эту защиту молча.
Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым
адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия,
предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
- **WHEN** сервис ставит куку сессии
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
#### Scenario: Предъявленный заголовок побеждает куку
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
#### Scenario: Слой не расширяется на поверхность хранилища
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой
- **THEN** значение куки в заголовок не перекладывается
- **GIVEN** человек узнан
- **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу
- **THEN** ответ несёт отказ
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
собранные логи, откуда её не убрать.
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя.
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
не убрать. Требование того же рода, что и запрет писать имя файла в хранилище:
там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым
довольно назваться, чтобы стать этим человеком.
Причина отказа, пришедшая от провайдера строкой запроса, MUST приводиться к
перечню известных: значение целиком задаёт тот, кто шлёт запрос, и без
приведения аноним пишет в журнал что угодно и сколько угодно.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
заголовка — тоже: это логин человека у провайдера.
#### Scenario: Значения сессии нет в журнале
Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом,
доступа сам по себе не даёт и без него путь запроса не прослеживается.
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой
- **THEN** значение сессии не встречается ни в одной журнальной записи
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
#### Scenario: Адреса почты нет в журнале
- **WHEN** человек проходит вход и учётная запись заводится
- **THEN** адрес его почты не встречается ни в одной журнальной записи
### Requirement: Сессия переживает перезапуск сервиса
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
#### Scenario: Прежняя кука годна после перезапуска
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** сервис поднимается заново на том же хранилище
- **THEN** запрос с прежней кукой проходит
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, и тем же числом
задавать срок жизни куки. Умолчание хранилища MUST не применяться: оно даёт пять
суток, и это число никем не выбрано.
Назначаться срок MUST при каждом подъёме, а не шагом схемы: применённый шаг не
переписывается, и число, положенное туда, разошлось бы со сроком жизни куки при
первой же правке — браузер получил бы новый срок, а хранилище продолжило выдавать
прежний.
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
человек, которому провайдер закрыл доступ, работает до истечения своей сессии.
Паспорт опирается на отзыв у провайдера как на способ остановить того, кто
тратит слишком много, — значит срок сессии и есть цена этой остановки.
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
#### Scenario: Сессия не продлевает саму себя
- **GIVEN** человек вошёл и получил сессию
- **WHEN** этой же сессией он просит продлить её
- **THEN** ответ несёт отказ, а нового значения в нём нет
#### Scenario: Сессия истекает назначенным сроком
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** назначенный срок прошёл
- **THEN** запрос с этой кукой получает отказ
#### Scenario: Владелец закрывает чужую сессию
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** владелец обесценивает сессии этой учётной записи
- **THEN** запрос с прежней кукой получает отказ
### Requirement: Выход прекращает доступ
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
у браузера. Куку сервис при этом MUST убрать тоже.
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
продолжало бы открывать доступ до самого своего истечения.
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
человек уверен, что вышел.
#### Scenario: После выхода прежняя кука не работает
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
- **THEN** запрос получает отказ
#### Scenario: Выход убирает куку
- **WHEN** человек выходит
- **THEN** ответ убирает куку сессии у браузера
- **WHEN** приходит первое обращение и учётная запись заводится
- **THEN** адрес почты не встречается ни в одной журнальной записи
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
настройка выкладки, лежащая вне репозитория.
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
сервиса — настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и клиент, настроенный слишком широко, открывает сервис
общий для контура, и правило, настроенное слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
#### Scenario: Пропущенный провайдером получает доступ
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
жизни этого значения. Теперь отзыв действует со следующего запроса.
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
- **THEN** учётная запись заводится, а доступ открывается
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
для заведения записи
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
прокси
- **THEN** доступ открывается, а учётная запись заводится, если её не было
- **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа
и адреса почты
#### Scenario: Отзыв у провайдера действует со следующего запроса
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
наблюдение за сервисом.
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
здоровья, ни у сборщика метрик учётной записи нет, и требование узнавания
остановило бы наблюдение за сервисом.
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
адреса не несут.
#### Scenario: Проба здоровья доступна анонимно
Заголовок, пришедший с недоверенного адреса, MUST не менять их ответа: узнавание
отказа не выдаёт, и посторонняя строка в запросе не вправе гасить наблюдение.
- **WHEN** запрос приходит на `GET /health` без сессии
#### Scenario: Проба здоровья доступна неузнанному
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ имеет код `200`
#### Scenario: Метрики доступны анонимно
#### Scenario: Метрики доступны неузнанному
- **WHEN** запрос приходит на `GET /metrics` без сессии
- **WHEN** запрос приходит на `GET /metrics` без заголовка
- **THEN** ответ имеет код `200`
### Requirement: Секрет провайдера живёт в конфиге
#### Scenario: Чужой заголовок наблюдения не гасит
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
а не заводиться однажды шагом схемы.
Причина второго требования в необратимости шага схемы: применённый шаг не
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
вовсе — вход сломался бы после ротации.
Незаполненная или негодная настройка входа MUST ронять старт с перечнем ключей и
без их значений. Форма адресов проверяется там же: непустая, но негодная строка
иначе отвергается хранилищем позже — из хука подъёма, до регистрации пробы
здоровья, — и сервис падает целиком, не оставив владельцу даже кода состояния.
#### Scenario: Секрета нет в журнале
- **WHEN** сервис поднимается с настроенным провайдером
- **THEN** значение секрета не встречается ни в одной журнальной записи
#### Scenario: Смена секрета доезжает до хранилища
- **GIVEN** сервис уже поднимался с прежним секретом
- **WHEN** секрет в конфиге заменён и сервис поднят заново
- **THEN** настройки провайдера в хранилище несут новое значение
#### Scenario: Негодная настройка роняет старт
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
- **THEN** старт кончается отказом, а отказ называет имена ключей
- **AND** значений этих ключей в отказе нет
- **WHEN** запрос на `GET /health` приходит с недоверенного адреса с заголовком
`Remote-User`
- **THEN** ответ имеет код `200`
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
держит capability `storage`.
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и
норму эту держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
@@ -387,20 +215,20 @@ capability `archive`: там живут адреса чтения записи,
#### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает карточку этой записи своей сессией
- **GIVEN** человек узнан и принял запись
- **WHEN** он спрашивает карточку этой записи
- **THEN** ответ несёт данные записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим
- **WHEN** её карточку спрашивает другой вошедший
- **GIVEN** запись принята одним узнанным
- **WHEN** её карточку спрашивает другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии
- **THEN** владельцем принятой записи становится узнанный предъявитель
#### Scenario: Ничью запись завести нечем
@@ -415,65 +243,393 @@ capability `archive`: там живут адреса чтения записи,
### Requirement: Приложение узнаёт вошедшего
Сервис SHALL отдавать приложению сведения о том, кто вошёл, — `GET /app/me` — и
MUST отвечать отказом `401`, когда сессии нет. Своей страницы со скриптом,
которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает
разметку само и вошедшего узнаёт ответом.
Сервис SHALL отдавать приложению сведения о том, кто пришёл, — `GET /app/me` — и
MUST отвечать отказом `401`, когда пришедший не узнан. Своей страницы со
скриптом, которой сервер отрисовал бы имя пришедшего, у сервиса нет: приложение
собирает разметку само и пришедшего узнаёт ответом.
Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не
может вовсе — этот адрес единственный способ его узнать.
Заголовок ставит прокси, и прочитать его из браузера приложение не может вовсе —
этот адрес единственный способ узнать, кто пришёл.
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
ему выходить наружу наравне с журналом.
#### Scenario: Вошедший узнан
#### Scenario: Пришедший узнан
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** приложение спрашивает, кто вошёл
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** приложение спрашивает, кто пришёл
- **THEN** ответ несёт идентификатор его учётной записи
#### Scenario: Сессии нет
#### Scenario: Пришедший не узнан
- **WHEN** приложение спрашивает, кто вошёл, без сессии
- **WHEN** приложение спрашивает, кто пришёл, без заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт учётной записи
#### Scenario: Адреса почты в ответе нет
- **GIVEN** человек вошёл, и у его учётной записи есть адрес почты
- **WHEN** приложение спрашивает, кто вошёл
- **GIVEN** пришедший узнан, и у его учётной записи есть адрес почты
- **WHEN** приложение спрашивает, кто пришёл
- **THEN** адреса почты в ответе нет
### Requirement: Приложение отдаётся без сессии
Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень
адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба
здоровья и метрики.
Сервис SHALL отдавать разметку приложения и её ресурсы неузнанному. Перечень
адресов, открытых без узнавания, пополняется ими: прежде в нём стояли только
проба здоровья и метрики.
Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через
приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена
открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого
записей не несут: они одинаковы для всех и собираются до всякого запроса.
Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал,
до приложения не доходит вовсе. Разметка при этом обязана отдаваться и ему —
иначе неудача узнавания выглядела бы поломкой сервиса, а не отказом входа. Цена
открытости названа здесь же и невелика: ни разметка, ни ресурсы содержимого
записей не несут — они одинаковы для всех и собираются до всякого запроса.
Открытость MUST не касаться данных: всякий адрес под корнем приложения
по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни
одной записи.
по-прежнему требует узнанного предъявителя, и приложение, открытое неузнанным,
не получает ни одной записи.
#### Scenario: Разметка доступна анонимно
#### Scenario: Разметка доступна неузнанному
- **WHEN** запрос приходит на корень сервиса без сессии
- **WHEN** запрос приходит на корень сервиса без заголовка
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Ресурс приложения доступен анонимно
#### Scenario: Ресурс приложения доступен неузнанному
- **WHEN** запрос приходит на ресурс приложения без сессии
- **WHEN** запрос приходит на ресурс приложения без заголовка
- **THEN** ответ имеет код `200`
#### Scenario: Данные анонимно не отдаются
#### Scenario: Данные неузнанному не отдаются
- **GIVEN** приложение открыто без сессии
- **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** журнал подъёма называет доверенные адреса
+4 -4
View File
@@ -51,7 +51,7 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
- отсутствие сессии`401`, и он MUST наступать **до всякого чтения записи**,
- пришедший не узнан`401`, и он MUST наступать **до всякого чтения записи**,
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
разнице кодов перебирается список заведённых записей;
- узнанный предъявитель без учётной записи пользователя — `403`;
@@ -130,16 +130,16 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
- **AND** код отказа принадлежит закрытому перечню
- **AND** ни одно из них не содержит сырого текста ошибки
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
- **GIVEN** заведена запись
- **WHEN** её карточку спрашивают без сессии, а затем спрашивают карточку по
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
неизвестному идентификатору
- **THEN** оба ответа имеют код `401` и одно тело
#### Scenario: Запись сверх потолка размера
- **GIVEN** отправитель предъявил сессию
- **GIVEN** отправитель узнан
- **WHEN** он шлёт запись длиннее потолка размера
- **THEN** ответ имеет код `413`, а тело несёт предел числом
- **AND** ни файла, ни аудиозаписи не заводится
+17 -17
View File
@@ -14,7 +14,7 @@
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
Запрос от неузнанного MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`.
@@ -50,7 +50,7 @@
самый частый отказ у человека на мобильной сети — прежде не был нормирован
ничем и уходил телом ограничителя тела, мимо единой формы.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
@@ -59,20 +59,20 @@
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Предъявитель, узнанный без учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ
неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно
такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него
нет, и владельцем записи он стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
@@ -83,31 +83,31 @@
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **AND** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
#### Scenario: Сессия не даёт учётной записи пользователя
#### Scenario: Узнанный без учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **GIVEN** предъявлен собственный токен владельца панели
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
#### Scenario: Пришедший не узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` без сессии
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **GIVEN** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
@@ -115,7 +115,7 @@
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **AND** отправитель узнан
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
+23 -15
View File
@@ -107,19 +107,21 @@ MUST завести свою схему и принимать записи св
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
коллекции. Правило MUST пускать только владельца файла: незаданное означает
«только владелец панели», и тогда файла не получит и вошедший, а прежнее «всякий
узнанный» отдавало чужое аудио тому, кто знает идентификатор записи.
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
Правило MUST пускать только владельца файла: незаданное означает «только владелец
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
отдавало чужое аудио тому, кто знает идентификатор записи.
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
токена: отказ наступает там, и требовать его от выдачи значит требовать
механизма, которого нет.
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
недосмотр.
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
хранилища, а не недосмотр.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
@@ -133,7 +135,7 @@ MUST завести свою схему и принимать записи св
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
@@ -147,17 +149,23 @@ MUST завести свою схему и принимать записи св
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **AND** забирающий предъявил сессию и взял по ней токен файла
- **AND** забирающий узнан и взял токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Без сессии файл не отдаётся
#### Scenario: Неузнанному файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают без сессии
- **WHEN** ссылку на файл запрашивают неузнанным
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Конвейер читает файл без сессии
#### Scenario: Токен файла выдаётся узнанному по заголовку
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он просит у хранилища токен файла
- **THEN** токен выдаётся
#### Scenario: Конвейер читает файл без узнавания
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
@@ -324,7 +332,7 @@ MUST завести свою схему и принимать записи св
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
@@ -348,8 +356,8 @@ MUST получать владельца своей записи. Иного и
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним вошедшим
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
+44 -51
View File
@@ -8,9 +8,7 @@
Спека отвечает за **сервис**, а не за сборщик: правило неизвестного пути, срок
хранения ответов, поведение при несобранном приложении и то, что уходит в журнал.
Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения.
## Requirements
### Requirement: Приложение отдаётся самим бинарником
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника.
@@ -71,8 +69,15 @@
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа,
`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`.
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, —
отдельными адресами стоят `/health` и `/metrics`.
Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и
адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем
же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя
за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается
от любого другого свободного имени, а второй перечень «когда-то занятых корней»
разошёлся бы с первым молча.
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
@@ -114,16 +119,23 @@
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес входа открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем входа
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
- **GIVEN** человек вошёл и предъявил сессию
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** он спрашивает неизвестный путь под корнем приложения
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
@@ -138,22 +150,6 @@
- **THEN** ответ имеет код `404`
- **AND** тело ответа — не разметка приложения
#### Scenario: Наблюдение приложением не подменяется
- **WHEN** запрос приходит на пробу здоровья
- **THEN** отвечает проба здоровья, а не приложение
#### Scenario: Чужой метод отвечает отказом
- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым
страницу не открывают
- **THEN** ответ имеет код `405`
#### Scenario: Проверка доступности разметку получает
- **WHEN** разметку спрашивают методом `HEAD`
- **THEN** ответ имеет код `200`
### Requirement: Обновлённое приложение доходит до браузера
Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим
@@ -239,44 +235,41 @@
### Requirement: Открытое приложение показывает вошедшего
Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ
`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком
адресе данных, и это его штатное состояние, а не поломка.
Приложение SHALL спрашивать сервис, кто пришёл, и показывать его имя. Отказ
`401` MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
только мимо него.
Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться
строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг:
приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе
сервиса или ограничителя частоты круг пошёл бы заново.
Всякий **иной** отказ и сорванный запрос MUST показываться строкой о неудаче.
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
перехода.
Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна
скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в
ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен,
и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме
`access`.
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
адрес почты: его в ответе нет по норме `access`.
#### Scenario: Вошедший виден
#### Scenario: Узнанный виден
- **GIVEN** человек вошёл и получил куку сессии
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
- **WHEN** он открывает приложение
- **THEN** приложение показывает его имя
#### Scenario: Не вошедшему предлагается вход
#### Scenario: Неузнанному показывают, что его не узнали
- **GIVEN** сессии у человека нет
- **WHEN** он открывает приложение
- **THEN** приложение ведёт его ко входу
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о том, что его не узнали
- **AND** никуда его не уводит
#### Scenario: Отказ сервиса ко входу не уводит
#### Scenario: Отказ сервиса от неузнавания отличается
- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является
отсутствием сессии
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
неузнаванием
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о неудаче
- **AND** ко входу оно не уводит
- **AND** эта строка не та, которой оно сообщает о неузнавании
#### Scenario: Учётная запись без имени
- **GIVEN** человек вошёл, а имени у его учётной записи нет
- **WHEN** он открывает приложение
- **THEN** приложение показывает, что вход выполнен
- **AND** адреса почты на экране нет