Files
transcriber/openspec/specs/access/spec.md
T
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00

32 KiB

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 адреса почты в ответе нет