- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
30 KiB
access Specification
Purpose
Кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
Здесь же разграничение записей по владельцу: с 2026-08-14 сессия отвечает не только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
Записи, принятые ботом, владельца не имеют вовсе и по API не достаются никому: связи чата Telegram с учётной записью приложения сервис не ведёт, её заводит отдельная задача.
Вход из 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 значений этих ключей в отказе нет
Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой записи, принятой по HTTP, — владельца, то есть учётную запись, от имени которой запись принята, — и MUST отдавать данные такой записи только её владельцу. Владелец назначается один раз, при приёме, и MUST не меняться у записи, у которой владелец есть: совместного доступа, ролей и передачи записи другому сервис не знает. Оговорка не случайна — назначить владельца записи, у которой его нет, вправе задача, заводящая связь чата Telegram с учётной записью.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец, пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability intake: там живёт адрес опроса, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью — ни со своей, ни с чужой, ни с ничьей. Правило записано со стороны спрашивающего, а не со стороны записи: обязательность владельца, которую держит одна лишь подпись метода, пустую строку пропускает, и первый же вызывающий без учётной записи получил бы ровно множество записей без владельца, то есть все записи бота.
Записи, принятые из Telegram, владельца не имеют: связи чата с учётной записью приложения сервис не ведёт. Такая запись MUST не доставаться по API никому — ответ на неё тот же, что и на несуществующую, — а её расшифровка уезжает отправителю в чат, как и прежде.
Scenario: Своя запись доступна
- GIVEN человек вошёл и принял запись
- WHEN он спрашивает состояние этой записи своей сессией
- THEN ответ несёт состояние записи
Scenario: Чужая запись неотличима от несуществующей
- GIVEN запись принята одним вошедшим
- WHEN её состояние спрашивает другой вошедший
- THEN ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
Scenario: Владельца не задают запросом
- WHEN запрос на приём записи несёт своё значение владельца
- THEN владельцем принятой записи становится предъявитель сессии
Scenario: Запись из Telegram не достаётся по API
- GIVEN запись принята ботом
- WHEN её состояние спрашивает вошедший человек
- THEN ответ тот же, что и на неизвестный идентификатор
Scenario: Пустой владелец не открывает ничего
- GIVEN заведены три задачи: своя, чужая и принятая ботом
- WHEN состояние каждой спрашивают с пустым владельцем
- THEN ответ на все три тот же, что и на неизвестный идентификатор