Files
transcriber/openspec/specs/access/spec.md
T
av c44f0e7582 HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
2026-08-12 17:44:22 +03:00

348 lines
25 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, чем предъявляется сессия, что её прекращает и какие адреса остаются
открытыми.
Разграничения записей по владельцу здесь **нет**: всякий вошедший видит ровно
то же, что видел прежде аноним. Его заводит отдельная задача, и до неё сессия
отвечает только на вопрос «узнан ли пришедший», а не «чьё он смотрит».
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
белым списком, и с учётной записью приложения тот список не связан.
## 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** он шлёт запрос к API с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
- **WHEN** сервис ставит куку сессии
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
#### Scenario: Предъявленный заголовок побеждает куку
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **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** значений этих ключей в отказе нет