HTTP API закрыт за вход через OIDC у Authelia

- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
This commit is contained in:
av
2026-08-12 17:44:22 +03:00
parent d676df8a27
commit c44f0e7582
37 changed files with 3997 additions and 65 deletions
@@ -0,0 +1,273 @@
## 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. Значение
выбирается при заведении клиента и попадает в конфиг; здесь оно не
фиксируется.