## Context Сегодня HTTP API открыт наружу без проверки — так записано первой строкой модели угроз. Приглашение второго человека упирается в это: у записей нет владельца, а подобранный идентификатор задачи отдаёт чужую расшифровку. Решение от 2026-08-11 (`ADR-2026-08-11-pocketbase-storage-with-admin-panel`) назвало способ: **ответ провайдера разбирает хранилище, а не наш код**. У коллекции пользователей включается провайдер `oidc` с адресами Authelia, учётные записи заводятся сами, и панель их видит. Проверено на версии 0.39.10 — `docs/research/pocketbase.md`, раздел «Пользователи — только те, кого туда положат». **Способ остаётся верным, но его механика уже проверена по исходникам библиотеки, и три ожидания постановки она не подтверждает.** Проверено чтением `pocketbase@v0.39.10`: 1. **Куки библиотека не читает вовсе.** Сессию она берёт единственным способом — заголовком `Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`). Критерий приёмки задачи написан про куку. 2. **Эндпоинта выхода библиотека не приносит.** Список её адресов аутентификации — `auth-methods`, `auth-refresh`, `auth-with-password`, `auth-with-oauth2`, `request-otp`, `auth-with-otp`, восстановление пароля, подтверждение почты и смена почты (`apis/record_auth.go`). Выхода среди них нет. 3. **Браузерный вход по редиректу библиотека своим не приносит.** Она приносит обмен уже полученного кода: `POST /api/collections/{c}/auth-with-oauth2` требует `provider`, `code`, `codeVerifier` и `redirectURL`. Её собственный `/api/oauth2-redirect` служит другому: он ищет клиента realtime-подписки по параметру `state` и отдаёт код туда (`apis/record_auth_with_oauth2_redirect.go`) — это механика её JS-клиента с всплывающим окном, а не серверный вход. Отсюда объём: инициировать вход, принять возврат и завести куку — наш код. Разбор ответа провайдера, заведение учётной записи и связь с внешним провайдером остаются за хранилищем, как и решено. Решение 2026-08-11 не пересматривается. ## Goals / Non-Goals **Goals:** - запрос к приёму записи и к опросу готовности без сессии получает отказ и ничего не заводит; - вход идёт у Authelia по OIDC, учётные записи заводятся сами; - выход закрывает доступ немедленно, а не по истечении срока; - сессия переживает выкладку; - проба здоровья и метрики остаются открытыми. **Non-Goals:** - владелец у записи и сужение выборки по нему — задача `record-ownership`; - вход для программ по личным токенам — отдельная цель роадмапа. Внешняя программа, ходившая в API анонимно, этим изменением ломается намеренно, и замены ей здесь не появляется; - белый список Telegram — живёт до `telegram-account-link`; - панель администратора — в неё провайдер не пускает, наружу её закрывает обратный прокси; - своя страница входа со скриптом: у сервиса нет фронтенда, и заводить его ради входа незачем. ## Decisions ### Сессия предъявляется кукой, а заголовок остаётся внутренним **Выбрано:** наш обработчик возврата ставит куку `HttpOnly`, `Secure`, `SameSite=Lax` со значением, выданным хранилищем. Промежуточный слой перед проверкой перекладывает значение куки в заголовок `Authorization`, если заголовка нет. Дальше работает штатная проверка библиотеки. Человек увидит обычный вход: перешёл, авторизовался у Authelia, вернулся — работает. Ни строки скрипта на его стороне. Отвергнуто: - **заголовок `Authorization` как единственный способ.** Это механика библиотеки и путь наименьшего кода, но браузер такой заголовок сам не шлёт: понадобился бы свой фронтенд, который держит значение и подставляет его. Фронтенда у сервиса нет, а заводить его ради входа — работа шире задачи. Критерий приёмки задачи вдобавок написан про куку; - **своя таблица сессий.** Даёт полный контроль над выходом и сроком, но заводит второй способ делать то, что хранилище уже делает, — и второй дом для факта «кто вошёл». Отвергнуто по концептуальной целостности. Заголовок при этом остаётся рабочим: его требуют собственные адреса аутентификации хранилища, и глушить их значит ломать библиотеку изнутри. Это осознанно оставленная вторая дверь, и она названа в спеке. ### Форма решения выбрана из трёх, а не из одной Прежде трёх решений ниже — выбор самой формы. Рассматривались три. **A — свой тонкий слой входа поверх хранилища.** Выбрана. Наш код ведёт флоу и ставит куку, обмен кода и заведение учётной записи остаются за хранилищем. Цена: сверка состояния и установка куки — наша ответственность, то есть ошибки в чувствительном месте наши. **B — вход целиком на обратном прокси.** Authelia стоит перед сервисом и не пускает неузнанные запросы, приложение доверяет заголовку от прокси. Нашего кода почти ноль. Отвергнуто по трём причинам сразу: при прямом обращении к порту заголовок подделывает кто угодно в той же сети, а сервис не имеет способа отличить прокси от постороннего; учётные записи в панели не появляются вовсе, а решение 2026-08-11 требует обратного; вход для программ по личным токенам из этой формы не вырастает — его пришлось бы делать заново и мимо. **C — фронтенд и штатный клиент хранилища.** Своя страница, всплывающее окно, подписка, значение сессии в хранилище браузера. Всё штатно для библиотеки. Отвергнуто: у сервиса нет фронтенда, и заводить его ради входа — работа шире задачи; значение сессии становится доступно скриптам страницы, то есть XSS уносит сессию целиком, тогда как кука с запретом чтения скриптом этого не даёт. ### Вход и возврат ведёт наш код, разбор ответа — хранилище **Выбрано:** три своих адреса — начало входа, возврат от провайдера, выход. Начало входа заводит `state` и PKCE-verifier, кладёт их во временную куку и уводит человека на `authURL` провайдера. Возврат сверяет `state`, а код отдаёт хранилищу вызовом его же обмена — тем, что стоит за `auth-with-oauth2`. **Обмен кода библиотека наружу не отдаёт** — он живёт неэкспортированной функцией за собственным адресом хранилища. Решением владельца от 2026-08-12 обработчик возврата зовёт **этот адрес внутри процесса**, через роутер хранилища, а не по сети. Цена названа и принята: получается петля «наш обработчик → наш роутер → наш обработчик», ответ разбирается текстом, а типизированная ошибка теряется. Взамен решение 2026-08-11 соблюдается дословно — разбор ответа провайдера остаётся за хранилищем, и учётные записи видны в панели. Отвергнуто: - **собрать обмен своими руками** из кусков, которые библиотека всё же отдаёт. Прямой код без петли, но разбор ответа провайдера переезжает к нам — это пересмотр решения 2026-08-11 отдельным ADR, и владелец его не выбрал; - **всплывающее окно и realtime-подписка**, как делает JS-клиент библиотеки. Работает без нашего кода вовсе, но требует того самого фронтенда и держит открытым realtime-соединение ради одного входа. ### Кого пускать, решает провайдер, а не сервис Решением владельца от 2026-08-12 сервис своей проверки допуска **не делает**: кто допущен, определяет правило Authelia на этого клиента. Всякий, кого провайдер пропустил, получает учётную запись и доступ. Цена принята и обязана быть записанной: правило живёт вне репозитория, в настройках выкладки, и сервис на него полагается так же, как полагается на обратный прокси в части панели администратора. Настроенный слишком широко клиент открывает сервис всем, у кого есть учётная запись в общей Authelia, — и проверить это по коду нельзя. Строка об этом идёт в `docs/security.md`, раздел «Что разграничивает доступ». Отвергнуто: **проверка группы своим кодом** — защита стояла бы в сервисе и не зависела от настройки контура, но владелец выбрал не заводить второе место, где решается допуск. ### Выход обесценивает выданные сессии, а не только убирает куку **Выбрано:** выход обновляет ключ токенов учётной записи (`Record.RefreshTokenKey()` плюс сохранение) и убирает куку. Подпись сессии считается от этого ключа, поэтому все прежние значения перестают проходить разом. Отвергнуто: - **только уборка куки.** Унесённое значение продолжало бы открывать доступ до истечения срока — то есть выход не закрывал бы доступ, а делал вид; - **чёрный список выданных значений.** Даёт точечный выход одной сессии, но требует своей таблицы и её чистки; при одном человеке и одном браузере это цена без покупателя. Цена выбранного названа прямо: выход закрывает **все** сессии учётной записи, а не только текущую. При сегодняшнем числе пользователей это незаметно, и переделка, когда станет заметно, — чёрный список из отвергнутого варианта. ### Сессия переживает перезапуск сама Проверено по исходникам: подпись считается от секрета коллекции (`Collection().AuthToken.Secret`) и ключа записи, оба лежат в базе (`core/record_query.go`, `FindAuthRecordByToken`). Значит требование выполняется устройством хранилища, и нашей работы здесь нет — есть проверка тестом. ### Настройки провайдера приводятся к конфигу при каждом запуске **Выбрано:** шаг схемы включает провайдера с пустыми значениями, а адреса, идентификатор клиента и секрет проставляются при подъёме сервиса из конфига. Причина в инварианте: **применённый шаг схемы не переписывается**. Проставь секрет однажды шагом — и ротация секрета в конфиге до хранилища не доедет вовсе, вход сломается после смены ключа, а починить это можно будет только руками в панели. Отвергнуто: - **секрет в шаге схемы.** Разбито инвариантом выше; - **настройка руками в панели.** Работает, но не воспроизводится: поднятый с нуля сервис оказывается без входа, и знание живёт в голове владельца. ### Что изменило ревью кода Три решения приняты владельцем 2026-08-12 уже после того, как код был написан: ревью нашло, что заявленное поведение не работает. **Продление сессии выключено.** Хранилище выдаёт сессию продлеваемой, и предъявитель менял своё значение на новое бессрочно, никуда не входя. При живом продлении семисуточный срок не значил ничего — а он объявлен единственным каналом, которым отзыв доступа у провайдера доходит до сервиса. Цена: вход раз в семь суток. Отвергнуто: сверяться с провайдером по расписанию (новая связь с Authelia и обработка её недоступности — работа шире задачи) и принять как есть (тогда паспорт теряет способ остановить того, кто тратит слишком много). **Файл записи открыт вошедшим.** Пометка поля защищённым сама по себе закрыла файл вообще для всех, кроме владельца панели: защищённый файл судится ещё и правилом просмотра коллекции, а незаданное правило означает «только суперпользователь». Назначено правило для всякого узнанного. Отвергнуто: оставить файл только панели — тогда задача про прослушивание записи начинается с того же вопроса. **Форма адреса провайдера проверяется на старте.** Непустая, но негодная строка проходила проверку конфига и отвергалась хранилищем позже — из хука подъёма, до регистрации пробы здоровья. Сервис падал целиком, вместе с ботом и воркерами, а у владельца не было даже кода состояния. Отвергнуто: поднимать пробу здоровья раньше настройки провайдера — это завело бы состояние «сервис жив, вход сломан», которого спека не описывает. ## Risks / Trade-offs - **Секрет клиента появляется в новом месте — в базе.** → Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в `error_text`; база в этом перечне не значится, и запрета не нарушает. Но место новое, и модель угроз обязана его назвать: чтение файла базы теперь равносильно чтению секрета клиента. Пишется в `docs/security.md` этой же задачей. - **Ломается внешняя программа, ходившая в API анонимно.** → Ломка намеренная и объявлена в предложении: это и есть предмет задачи. Замены для программ (личные токены) в этом изменении нет — она отдельной целью. - **Вторая дверь: заголовок `Authorization` остаётся принимаемым.** → Он предъявляет ту же сессию и той же проверке, поэтому обхода не даёт. Но это второй способ войти, и в спеке он назван, чтобы не был обнаружен ревью как находка. - **Выход закрывает все сессии учётной записи.** → Названо решением выше, цена принята. - **PKCE-verifier и `state` живут во временной куке.** → Кука ставится на время входа, `HttpOnly` и `SameSite=Lax`, и убирается на возврате. Хранить их в памяти процесса нельзя: выкладка посреди входа роняла бы вход. - **Признак `Secure` закрывает локальный запуск.** → Браузер не сохранит такую куку по `http://localhost`, и вход перестанет работать у того, кто поднимает сервис командой из раздела команд. Признак берётся из конфига с умолчанием «включено», и расхождение образца называется строкой в `docs/conventions/config.md`. - **Коллекция пользователей остаётся умолчательной `users`.** → Своя коллекция означала бы задание правил и способов входа с нуля вместо подчистки умолчаний, а переезд позже — перевязку связей с провайдером и обесценивание всех выданных сессий. Цена умолчательной: её заводит системный шаг библиотеки с открытым созданием записи, и закрывать это приходится нам. - **Проверить вход целиком без живой Authelia нельзя.** → Тесты закрывают сверку `state`, отказ без сессии, выход и сохранность сессии; живой вход у провайдера остаётся ручной проверкой владельца на выкладке. Это граница покрытия, и она называется в докладе. ## Migration Plan Шаг схемы включает провайдера у коллекции пользователей и накатывается при подъёме, как и прежние шаги. Данных он не трогает: ни одной записи не переписывается, учётные записи заводятся сами при первом входе. Откат — прежний образ: шаг схемы обратим своим `down`, а до первого входа в коллекции пользователей пусто. Порядок выкладки: сперва завести клиента в Authelia и получить секрет, потом положить его в конфиг на сервере, потом выкладывать. Обратный порядок поднимает сервис с провайдером без секрета — вход не работает, а API уже закрыт. ## Open Questions - Адрес возврата должен совпадать с тем, что записан клиенту в Authelia. Значение выбирается при заведении клиента и попадает в конфиг; здесь оно не фиксируется.