- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
274 lines
23 KiB
Markdown
274 lines
23 KiB
Markdown
## 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. Значение
|
||
выбирается при заведении клиента и попадает в конфиг; здесь оно не
|
||
фиксируется.
|