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