Files
transcriber/openspec/specs/access/spec.md
T
av 663021f712 приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
2026-08-15 18:51:05 +03:00

480 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# access Specification
## Purpose
Кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
открытыми.
Здесь же разграничение записей по владельцу: с 2026-08-14 сессия отвечает не
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
Записи без владельца у сервиса не бывает: колонка владельца пустого значения не
принимает, и норму эту держит capability `storage`. Прежде такие записи заводил
вход 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 быть
выключены настройкой коллекции.
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования закрытие приёма обходится
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
настоящему входу с этим адресом.
Закрытие MUST не отменять заведения записи самим входом: запись при первом входе
заводит внутренний запрос обмена, и правило, отвергающее его наравне с
посторонним, оставляет сервис без единого способа войти.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Вход у провайдера запись заводит
- **GIVEN** учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход у провайдера
- **THEN** учётная запись появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а сессия не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
### Requirement: Сессия предъявляется кукой
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
(`SameSite=Lax` или строже).
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
Сервис MUST перекладывать значение куки в этот заголовок **только когда
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена **адресами приложения** — теми, что
живут под его собственным корнем. Собственная поверхность хранилища под него не
подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не
шлёт, и расширение слоя на всё сняло бы эту защиту молча.
Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым
адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия,
предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
- **WHEN** сервис ставит куку сессии
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
#### Scenario: Предъявленный заголовок побеждает куку
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
#### Scenario: Слой не расширяется на поверхность хранилища
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой
- **THEN** значение куки в заголовок не перекладывается
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
собранные логи, откуда её не убрать.
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
Причина отказа, пришедшая от провайдера строкой запроса, MUST приводиться к
перечню известных: значение целиком задаёт тот, кто шлёт запрос, и без
приведения аноним пишет в журнал что угодно и сколько угодно.
#### Scenario: Значения сессии нет в журнале
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой
- **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** ответ убирает куку сессии у браузера
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
#### Scenario: Пропущенный провайдером получает доступ
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
- **THEN** учётная запись заводится, а доступ открывается
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
для заведения записи
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
наблюдение за сервисом.
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
адреса не несут.
#### Scenario: Проба здоровья доступна анонимно
- **WHEN** запрос приходит на `GET /health` без сессии
- **THEN** ответ имеет код `200`
#### Scenario: Метрики доступны анонимно
- **WHEN** запрос приходит на `GET /metrics` без сессии
- **THEN** ответ имеет код `200`
### Requirement: Секрет провайдера живёт в конфиге
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
а не заводиться однажды шагом схемы.
Причина второго требования в необратимости шага схемы: применённый шаг не
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
вовсе — вход сломался бы после ротации.
Незаполненная или негодная настройка входа MUST ронять старт с перечнем ключей и
без их значений. Форма адресов проверяется там же: непустая, но негодная строка
иначе отвергается хранилищем позже — из хука подъёма, до регистрации пробы
здоровья, — и сервис падает целиком, не оставив владельцу даже кода состояния.
#### Scenario: Секрета нет в журнале
- **WHEN** сервис поднимается с настроенным провайдером
- **THEN** значение секрета не встречается ни в одной журнальной записи
#### Scenario: Смена секрета доезжает до хранилища
- **GIVEN** сервис уже поднимался с прежним секретом
- **WHEN** секрет в конфиге заменён и сервис поднят заново
- **THEN** настройки провайдера в хранилище несут новое значение
#### Scenario: Негодная настройка роняет старт
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
- **THEN** старт кончается отказом, а отказ называет имена ключей
- **AND** значений этих ключей в отказе нет
### 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** человек вошёл и получил куку сессии
- **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`