вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
@@ -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
@@ -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** журнал подъёма называет доверенные адреса
|
||||
|
||||
|
||||
@@ -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** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
@@ -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`: собственного порога по размеру у приёма нет
|
||||
|
||||
|
||||
@@ -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: Свой файл отдаётся
|
||||
|
||||
@@ -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** адреса почты на экране нет
|
||||
|
||||
Reference in New Issue
Block a user