HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-12
|
||||
@@ -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. Значение
|
||||
выбирается при заведении клиента и попадает в конфиг; здесь оно не
|
||||
фиксируется.
|
||||
@@ -0,0 +1,54 @@
|
||||
## Why
|
||||
|
||||
HTTP API открыт наружу без всякой проверки: кто угодно из интернета заводит
|
||||
задачи расшифровки за наши деньги и читает чужие расшифровки, подобрав
|
||||
идентификатор задачи. Сегодняшний периметр так и записан в модели угроз —
|
||||
аутентификации не делает ни обратный прокси, ни само приложение.
|
||||
|
||||
Второго человека пригласить в сервис сейчас нельзя: это значит открыть ему всё,
|
||||
что в сервисе уже лежит.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Сервис узнаёт, кто к нему пришёл. Учётные записи заводит и проверяет внешний
|
||||
провайдер — Authelia по OIDC; своей регистрации и своих паролей не заводим.
|
||||
- **BREAKING** Приём записи и опрос готовности задачи требуют входа: запрос без
|
||||
сессии получает отказ и не заводит задачу, а текста расшифровки не отдаёт.
|
||||
Внешняя программа, ходившая в API без всякого входа, перестаёт работать.
|
||||
- Появляются вход и выход: вход уводит человека к провайдеру и возвращает
|
||||
обратно уже узнанным, выход закрывает доступ немедленно.
|
||||
- Проба здоровья и метрики остаются открытыми и сессии не требуют: ни у пробы,
|
||||
ни у сборщика метрик её нет. Наружу их закрывает правило обратного прокси —
|
||||
это работа выкладки.
|
||||
- Разграничения записей по владельцу здесь **нет**: после входа человек видит
|
||||
ровно столько же, сколько видно сейчас.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `access`: кто пришёл в сервис и пускают ли его дальше — вход через внешнего
|
||||
провайдера, чем предъявляется сессия, что её прекращает и какие адреса
|
||||
остаются открытыми.
|
||||
|
||||
### Modified Capabilities
|
||||
- `intake`: приём записи и опрос готовности задачи перестают быть доступны
|
||||
анонимно — оба требуют узнанного отправителя.
|
||||
- `storage`: ссылка на файл записи перестаёт быть правом пройти по ней — файл
|
||||
отдаётся только узнанному отправителю.
|
||||
|
||||
## Impact
|
||||
|
||||
- Коллекция пользователей хранилища: включённый провайдер `oidc` с адресами
|
||||
Authelia, идентификатором клиента и секретом; связь учётной записи с внешним
|
||||
провайдером хранилище ведёт своей служебной коллекцией.
|
||||
- `POST /api/audio` и `GET /api/status/{id}` — публичный контракт HTTP API
|
||||
объявлен проектом необратимым, и здесь он меняется: у обоих появляется отказ
|
||||
без входа.
|
||||
- Новые адреса входа, возврата от провайдера и выхода.
|
||||
- Секция конфигурации под провайдера: адрес, идентификатор клиента, секрет.
|
||||
Секрет попадает в настройки коллекции хранилища — это новое место, где он
|
||||
живёт, и его надо назвать в модели угроз.
|
||||
- `config.dist.toml` и разбор конфига.
|
||||
- `docs/security.md`: первая строка периметра перестаёт быть верной.
|
||||
- Панель администратора не трогается: в неё провайдер не пускает, и закрывает её
|
||||
обратный прокси.
|
||||
@@ -0,0 +1,313 @@
|
||||
# Ревью кода: oidc-login — триаж
|
||||
|
||||
База диффа: `origin/master`, изменение целиком в рабочем дереве. Дата прогона: 2026-08-12.
|
||||
|
||||
---
|
||||
|
||||
## Что сделано по итогам (дописано оркестратором после отработки)
|
||||
|
||||
Все семь пунктов закрыты; три развилки решены владельцем 2026-08-12.
|
||||
|
||||
| Пункт | Исход |
|
||||
|---|---|
|
||||
| 1. Войти не может никто | Исправлено: `CreateRule = @request.context = "oauth2"` правкой неуехавшего шага. Оракул — `TestLoginCreatesAccountAndSession`: вход целиком через подставного провайдера, учётная запись заводится, сессия выдаётся |
|
||||
| 2. Утечка обработчиков | Исправлено: роутер хранилища собирается один раз через `sync.Once`. Оракул — `TestCallbackDoesNotLeakHooks`: 20 возвратов не меняют длины очереди |
|
||||
| 3. Файл не отдаётся вошедшему | **Решение владельца: открыть вошедшим.** Назначено `files.ViewRule = @request.auth.id != ""`, спека дополнена порядком «сессия → токен файла → ссылка». Оракул — `TestRecordFileNeedsSessionAndToken` |
|
||||
| 4. Носитель состояния не убирался | Исправлено: уборка перенесена до записи ответа; все проверки файла судят по `w.Result()`, а не по живой карте заголовков |
|
||||
| 5. Опечатка в `[auth]` роняет процесс | **Решение владельца: проверять форму адреса на старте.** `Validate` разбирает адреса и требует схему и хост. Оракул — `TestAuthConfigValidateRejectsMalformedURL` |
|
||||
| 6. Отзыв доступа не доходит | **Решение владельца: выключить продление.** Заведён слой `BlockSessionRefresh`; спека и модель угроз дополнены. Оракул — `TestSessionRefreshIsClosed` |
|
||||
| 7. Сердцевина не покрыта | Исправлено: заведён `login_test.go` (вход целиком, отказ обмена, утечка, продление, файл) и `internal/config/config_test.go`; закрыты ветки выхода |
|
||||
|
||||
Сверх семи, из срезанного потолком, исправлено там же, где окно закрывается мерджем:
|
||||
|
||||
- **G** — срок жизни сессии перенесён из шага схемы в приведение настроек при подъёме;
|
||||
- **L** — откат шага больше не падает на валидации и не возвращает открытую регистрацию;
|
||||
- **O** — `docs/database.md` приведён к коду, коллекция `users` описана, три числа внесены в таблицу;
|
||||
- **Q** — уровни журнала разведены по адресату, добавлено поле `capability`;
|
||||
- **R** — `auth.client_secret` внесён в перечень секретов и в инвариант `CLAUDE.md` с изъятием про базу;
|
||||
- **N** — причина отказа провайдера приводится к перечню известных кодов;
|
||||
- язык ответов пользователю переведён на русский.
|
||||
|
||||
**Не сделано намеренно, ушло в урожай:** `P` (настройка `docs/.docs.json` указывает на несуществующий каталог миграций — дефект гейта, не этого изменения), `M` (код провайдера в журнале запросов хранилища), `S` (начало входа собрано руками), `T`/`U` (рантбук выкладки), `V` (проверка конфига не в норме), гипотезы без пути и остаток пункта 4 (серверный учёт употреблённых состояний).
|
||||
|
||||
---
|
||||
|
||||
## Сводка
|
||||
|
||||
**Размер, сложность, метка.** Размер — крупное: 24 подзадачи в 6 группах, 5 слоёв кода, 8 узлов в «Затрагивает», 2 capability (одна ADDED целиком). Сложность — незнакомое: `docs/review.md`, «Триггеры метки», «Незнакомое здесь» называет вход через OIDC дословно первой строкой. **Метка `large`** (максимум по осям), **режим — по графу**, опиниативные проходы открыты.
|
||||
|
||||
**Состояние гейта: ЗЕЛЁНЫЙ.** Проверено собственным прогоном триажа, а не только отчётом прохода: `task gate BASE=origin/master` → exit 0, все девять шагов. Унаследованное замечание `tasks.py` (`any-audio-source`) шаг не роняет и к диффу отношения не имеет.
|
||||
|
||||
**Зелёный гейт здесь — часть находки, а не свидетельство.** Два теста в `internal/controller/http/auth_test.go` утверждают проверенным то, что не работает (пункты 3 и 4), и оба зелёные. Два пункта `tasks.md` — 5.5 и 5.8 — отмечены `[x]` за проверки, которых в файле нет.
|
||||
|
||||
### План разметки задачи с исходом по каждой теме
|
||||
|
||||
| тема | дом | глубина | кто закрывает | исход |
|
||||
|---|---|---|---|---|
|
||||
| requirements | `openspec/changes/oidc-login/specs/{access,intake,storage}/spec.md` | разбор | specs | **закрыта**, 5 находок (C, D, E, M, V) + 3 блока наблюдений |
|
||||
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | **закрыта**, 3 находки (H, I, J) + отчёт гейта и `govulncheck` |
|
||||
| conventions | `docs/conventions/{config,database,errors,logging}.md` | разбор | code | **закрыта**, 7 находок (A, L, O, P, Q, R + доля в B) |
|
||||
| architecture | `docs/architecture.md`; источник `docs/passport.md` | доказательство | architecture | **закрыта**, 3 находки (B, G, S) + 5 пунктов «дешевле переделать» |
|
||||
| security | `docs/security.md` | доказательство | adversary | **закрыта**, 6 находок (A, B, E, F, M, N) + 5 свойств без пути |
|
||||
| operations | `docs/architecture.md` «Эксплуатация»; источник `docs/database.md` | доказательство | ops | **закрыта**, 3 находки (K, T, U) |
|
||||
|
||||
**Тем без отчёта нет.** Все шесть тем ядра вернули отчёты. `review-basics` не запускался — по решению `review-scope`: своих тем сверх ядра у проекта нет. Тем, унесённых непроверенными, нет.
|
||||
|
||||
**Сигнал о заниженной метке: не поступил, и провенанс у этого один.** `review-code` подал строку прямо: «метка `large` соответствует изменению, понижения не вижу». `review-basics` не запускался, поэтому второго независимого корректора метки у прогона не было — согласия двух проходов нет, есть отсутствие возражения от одного.
|
||||
|
||||
**Находок на входе:** 22 нумерованных (A–V) плюс 23 ненумерованных содержательных пункта (8 «поведение вне спеки», 5 «границы спеки», 5 «свойства без построенного пути», 5 «дешевле переделать») = **45 позиций**. **Осталось в основных секциях: 7** — 3 блокирующие и 4 «стоит исправить сейчас». Слито по причине 4 группы, понижено до гипотез 5, уехало в promote 6, выброшено как вкусовщина 3, срезано потолком с поимённым перечислением 13.
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. После выкладки войти не может никто, включая владельца — первый вход не заводит учётной записи
|
||||
|
||||
- Файл: `internal/adapter/repo/pocketbase/migrations.go:149`
|
||||
- Severity: `critical` · Confidence: `high`
|
||||
- Найдено проходами: code, adversary (2 прохода, оракула два разных; согласие приоритет повышает, `confidence` — нет)
|
||||
- **Оракул — мой собственный, добыт на этом прогоне.** Тест против настоящего PocketBase с подставным провайдером OIDC (`httptest`, token + userinfo), запущен через `go test -overlay=…` без записи в дерево проекта:
|
||||
|
||||
```
|
||||
users.CreateRule = <nil>
|
||||
ИСХОД: код=401 обращений к токен-эндпоинту=1 учётных записей=0
|
||||
тело={"error":"Login failed"}
|
||||
ERROR Failed to exchange provider code error="storage rejected the exchange with code 403"
|
||||
```
|
||||
|
||||
Причина изолирована тем же прогоном: с `CreateRule = @request.context = "oauth2"` результат `код=302 учётных записей=1 Location="/"`, при этом анонимный `POST /api/collections/users/records` по-прежнему получает `400`. Подтверждение по исходникам библиотеки: `apis/record_crud.go:230-232` (`!hasSuperuserAuth && collection.CreateRule == nil` → Forbidden); внутренний запрос обмена идёт без авторизации.
|
||||
- Последствие: шаг схемы закрывает создание записи для всех, кроме суперпользователя, а запись при первом входе заводит именно внутренний запрос обмена. Ни одной записи `users` шаг схемы не создаёт, приём и опрос закрыты сессией. После выкладки HTTP-вход не работает ни у кого; жив только Telegram. Лечится руками в панели — ровно то, от чего задача уходила.
|
||||
- Предложение: `users.CreateRule = ptr("@request.context = \"oauth2\"")` **правкой самого шага `up202608120001`**: он ещё не уезжал на сервер, и окно закрывается мерджем — после выкладки то же изменение потребует нового шага. **Не** чинить открытием `CreateRule = ""`: публичный `auth-with-oauth2` принимает `createData`, и всякий владелец учётной записи Authelia задаст поля новой записи сам.
|
||||
- Действие: **инлайн**
|
||||
|
||||
### 2. Всякий анонимный запрос на возврат навсегда добавляет обработчики приложению и дёргает Authelia нашим секретом
|
||||
|
||||
- Файл: `internal/controller/http/auth.go:224-234`
|
||||
- Severity: `critical` · Confidence: `high`
|
||||
- Найдено проходами: architecture, adversary (`critical`), specs и code (`minor`) — 4 прохода, оракула два
|
||||
- **Оракул — мой собственный, тот же прогон:**
|
||||
|
||||
```
|
||||
OnModelAfterCreateSuccess: до=5 после 50 возвратов=55
|
||||
OnModelAfterUpdateSuccess: до=6 после=106
|
||||
обращений к провайдеру за 50 анонимных возвратов: 50
|
||||
провайдер недоступен: обработчиков до=5 после 10 возвратов=15
|
||||
```
|
||||
|
||||
Последняя строка важна: утечка происходит **раньше** сетевого обращения и работает при мёртвом провайдере. Путь анонимный: атакующий сам ставит себе куку `transcriber_login=S:V` и зовёт `/auth/callback?state=S&code=x` — сверка сравнивает две его же величины. Причина: `apis.NewRouter` (`apis/base.go:47,53`) зовёт `bindRealtimeEvents` и `bindUIExtensions`, которые вешают обработчики **на приложение** без поля `Id`; `hook.Bind` (`tools/hook/hook.go:64`) дописывает, а не заменяет.
|
||||
- Последствие: рост линейный и не освобождается до перезапуска; каждое сохранение задачи конвейером проходит по всем накопленным замыканиям (замер adversary: 3000 хитов → 100 сохранений 3.86ms → 59.8ms, куча +5013 КиБ). Побочно каждый анонимный запрос гонит обмен к Authelia нашими `client_id`/`client_secret`.
|
||||
- Предложение: строить роутер один раз (при подъёме либо `sync.Once`), держать `http.Handler` полем `AuthHandler`. Решение владельца о петле внутри процесса не пересматривается.
|
||||
- Остаток, который правка не закрывает и который я не заказываю: анонимный запрос по-прежнему вызывает исходящее обращение к провайдеру. Ограничение числа запросов в scope изменения не входит; названо, чтобы не потерялось.
|
||||
- Действие: **инлайн**
|
||||
|
||||
### 3. Файл записи не отдаётся ни одному вошедшему — сценарий дельты не исполняется, а `docs/security.md` уже утверждает обратное
|
||||
|
||||
- Файл: `internal/adapter/repo/pocketbase/migrations.go:168-179`; тест `internal/controller/http/auth_test.go:405-431`; `docs/security.md:112-114`; `openspec/changes/oidc-login/tasks.md:68`
|
||||
- Severity: `major` · Confidence: `high`
|
||||
- Найдено проходами: specs, code, adversary
|
||||
- **Оракул — мой собственный, тот же прогон:**
|
||||
|
||||
```
|
||||
files.ViewRule = <nil>
|
||||
вошедший кукой → 404
|
||||
вошедший заголовком → 404
|
||||
файловый токен получен: код=200 непусто=true
|
||||
вошедший файловым токеном → 404
|
||||
аноним → 404
|
||||
```
|
||||
|
||||
Причина: `Protected=true` включает проверку по файловому токену **и** по `ViewRule` коллекции (`apis/file.go:109-134`); `ViewRule` у `files` не назначался ни одним шагом → `nil` → доступ только суперпользователю (`core/record_query.go:606-608`).
|
||||
- Последствие: сценарий дельты `storage` «GIVEN забирающий предъявил сессию THEN приходит тот же файл» не исполняется. Пункт `tasks.md` 5.8 («без сессии отдаёт отказ, **а с сессией — тот же файл**») отмечен сделанным, а тест проверяет только отказ анониму. `docs/security.md:113` уже переписан утверждением «пройти по ссылке теперь можно только с сессией»: сегодня это ложно. Заведённая задача про прослушивание записи упрётся сюда и, вероятнее всего, «починит» снятием `Protected`, вернув «знание ссылки = доступ».
|
||||
- Действие: **развилка**
|
||||
|
||||
**Вопрос владельцу.** Файл записи защищён так, что его не получает никто, кроме владельца панели. Что делаем:
|
||||
1. назначить `files.ViewRule` для вошедших и описать в спеке шаг «сессия → файловый токен → ссылка» (цена: правка шага схемы + новый абзац нормы + тест; окно на правку неуехавшего шага закрывается мерджем);
|
||||
2. переписать требование как «файл виден только владельцу в панели», снять сценарий из дельты `storage` и поправить `docs/security.md` (цена: правка нормы, задача про прослушивание записи начинается с этого же вопроса);
|
||||
3. оставить как есть и записать расхождение (цена: норма и код разошлись сознательно, следующий проход найдёт то же самое).
|
||||
|
||||
---
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 4. Носитель состояния входа живёт 10 минут вместо одного входа, и стерегущий его тест зелёный ложно
|
||||
|
||||
- Файл: `internal/controller/http/auth.go:131` (defer), `293-303`; тест `internal/controller/http/auth_test.go:272`
|
||||
- Severity: `major` · Confidence: `high`
|
||||
- Найдено проходами: specs, code (сведено с находкой «одноразовость состояния не реализована ничем» — причина одна: единственным механизмом одноразовости была уборка куки)
|
||||
- **Оракул — мой собственный, тот же прогон, настоящий сервер против recorder'а:**
|
||||
|
||||
```
|
||||
НАСТОЯЩИЙ СЕРВЕР: код=401 Set-Cookie=[]
|
||||
RECORDER rec.Header()=[transcriber_login=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax]
|
||||
RECORDER rec.Result().Header=[]
|
||||
УСПЕХ: код=302 Location="/" Set-Cookie=[transcriber_session=…; Max-Age=604800; HttpOnly; Secure; SameSite=Lax]
|
||||
```
|
||||
|
||||
На успешной ветке уборки состояния тоже нет — в ответе только кука сессии. Причина: `e.SetCookie` правит карту заголовков, а `defer` исполняется **после** `e.JSON`/`e.Redirect`, которые уже позвали `WriteHeader`. `net/http` при `WriteHeader` фиксирует снимок; `httptest.ResponseRecorder` наоборот — `Header()` отдаёт живую карту, а снимок лежит в `snapHeader`, который читает `Result()`.
|
||||
- Последствие: спека требует «MUST убираться на возврате — и на успешном, и на отказном»; носитель не убирается ни там, ни там и остаётся годным 10 минут. Повторно поданный URL возврата проходит сверку и уходит в обмен; отказ наступает только потому, что код у провайдера одноразовый — гарантия перенесена на внешнюю систему, чего спека не допускает. Тест утверждает обратное и проходит, потому что читает `w.Header()`.
|
||||
- Предложение: убирать куку **до** записи ответа (как уже сделано в `Logout`); всем тестам этого файла судить по `w.Result()`.
|
||||
- Остаток: `tasks.md` 5.5 обещает ещё и «повторный возврат с уже употреблённым состоянием», то есть учёт употреблённых состояний. Уборка куки закрывает переигрывание тем же браузером, но не учёт как таковой. Нужен ли учёт — вопрос владельцу, отдельно от этой правки; в код его сейчас не заказываю.
|
||||
- Действие: **инлайн**
|
||||
|
||||
### 5. Опечатка в `[auth]` кладёт весь сервис детерминированно, и `/health` в этот момент ещё не зарегистрирован
|
||||
|
||||
- Файл: `main.go:220-234`; `internal/config/config.go:69-90`
|
||||
- Severity: `major` · Confidence: `high`
|
||||
- Найдено проходом: ops
|
||||
- Оракул: эксперимент прохода — `ApplyProviderSettings` с непустым, но негодным URL → `oauth2: (providers: (0: (authURL: must be a valid URL; tokenURL: …).).)`. Проверено мной по коду: `AuthConfig.Validate` (`config.go:69-90`) сверяет **только непустоту** шести ключей; `ApplyProviderSettings` зовётся внутри хука `OnServe` **выше** регистрации `/health` и `/metrics` (`main.go:227-234` против `main.go:239+`), а ошибка хука прерывает `apis.Serve` до открытия порта (`apis/serve.go:216-269`). Далее общий shutdown останавливает бот и все три воркера.
|
||||
- Последствие: пробел от шаблона или отсутствующая схема в адресе роняет сервис целиком при каждом перезапуске, и у владельца нет даже кода состояния — только текст в журнале контейнера. Это первая выкладка этой секции конфига, шесть новых ключей.
|
||||
- Действие: **развилка**
|
||||
|
||||
**Вопрос владельцу.** Негодный адрес провайдера сегодня валит процесс молча. Что делаем:
|
||||
1. проверять форму URL в `Validate()` — отказ переезжает на старт, называет ключ поимённо и виден в журнале сразу (цена: три строки, поведение «не поднимаемся с кривым входом» сохраняется);
|
||||
2. регистрировать `/health` и `/metrics` **до** `ApplyProviderSettings` — сервис поднимается и честно отвечает о своём состоянии (цена: появляется состояние «сервис жив, вход сломан», которого спека не описывает);
|
||||
3. и то и другое.
|
||||
|
||||
### 6. Отзыв доступа в Authelia не доходит до сервиса никогда: предъявитель продлевает сессию сам
|
||||
|
||||
- Файл: `internal/adapter/repo/pocketbase/migrations.go:126-130` (комментарий); `openspec/changes/oidc-login/specs/access/spec.md:185-188`; `docs/security.md:145`
|
||||
- Severity: `major` · Confidence: `high`
|
||||
- Найдено проходом: adversary
|
||||
- **Оракул — мой собственный, тот же прогон:**
|
||||
|
||||
```
|
||||
auth-refresh #1 → код=200 новый токен непуст=true
|
||||
auth-refresh #2 → код=200 новый токен непуст=true
|
||||
auth-refresh #3 → код=200 новый токен непуст=true
|
||||
продлённое значение на /api/status → 404 (401 значило бы, что не работает)
|
||||
```
|
||||
|
||||
Замер adversary добавляет растущий `exp` (13:32:59 → 13:33:00 → 13:33:01 → 13:33:03) и claim `refreshable=true`.
|
||||
- Последствие: спека дельты `access` объявляет срок сессии «единственным, что доносит до сервиса отзыв доступа у провайдера», и на этом утверждении стоит ссылка паспорта на отзыв в Authelia как на способ остановить перерасход. Канала нет: предъявитель одного живого значения продлевает себе доступ бессрочно, никуда не входя. Аноним так не может — нужен живой токен.
|
||||
- Действие: **развилка**
|
||||
|
||||
**Вопрос владельцу.** Что делаем с продлением сессии:
|
||||
1. выключить продление (закрыть `auth-refresh` для `users`) — отзыв начинает доходить за семь суток, как обещает норма (цена: человек перевходит раз в неделю);
|
||||
2. сверяться с провайдером по расписанию (цена: новая связь с Authelia, обработка её недоступности, вне текущего scope);
|
||||
3. принять как есть и **сейчас же** убрать из `specs/access/spec.md`, `docs/security.md` и комментария шага схемы утверждение про канал отзыва (цена: паспорт теряет способ остановить перерасход, и это надо записать явно).
|
||||
|
||||
При любом варианте утверждение о канале отзыва сегодня ложно — правка нормы обязательна во всех трёх.
|
||||
|
||||
### 7. Сердцевина входа не исполнялась ни одним тестом: обмен кода, выдача куки сессии и проверка конфига
|
||||
|
||||
- Файл: `internal/controller/http/auth.go:199-252, 269-279`; `internal/config/config.go:69-90`; `internal/controller/http/auth.go:174-195`
|
||||
- Severity: `major` · Confidence: `high`
|
||||
- Найдено проходами: autotests, specs (сведены три находки: покрытие `exchange` и `setSessionCookie`, непокрытая `AuthConfig.Validate`, непокрытые ветки отказа `Logout` — причина одна: проверки останавливаются раньше сердцевины)
|
||||
- **Оракул — мой собственный прогон покрытия:**
|
||||
|
||||
```
|
||||
auth.go:199 exchange 0.0%
|
||||
auth.go:269 setSessionCookie 0.0%
|
||||
auth.go:128 Callback 54.5%
|
||||
auth.go:174 Logout 63.6%
|
||||
config.go:69 Validate 0.0% (у пакета internal/config нет файла тестов вовсе)
|
||||
```
|
||||
|
||||
- Последствие: единственный код, который меняет код провайдера на сессию, и код, который выдаёт сессию браузеру, не проверены ни на успех, ни на отказ. Спека объявляет имя `transcriber_session` нормативным именно потому, что «тест, ставящий и читающий одно и то же имя, этого не замечает» — потеря `HttpOnly`/`Secure`/`SameSite`, смена имени или срока пройдут гейт зелёными. Класс не новый: `docs/review.md`, журнал, записи 2026-08-10 («тесты http-обработчика ни разу не были зелёными») и 2026-08-11 («проверка приёма не могла упасть») — тот же род, третье появление.
|
||||
- Предложение: поднять подставного провайдера `httptest` и пройти `Callback` до `302` + куки, судя по `w.Result().Cookies()`, сверяя литерал имени, флаги и `MaxAge`; завести файл тестов `internal/config`; закрыть обе ветки отказа `Logout`.
|
||||
- Действие: **инлайн**
|
||||
|
||||
---
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **Чужая страница гасит сессию.** `POST /auth/logout` сессии не требует и на кросс-сайтовом запросе отвечает `200` с `Set-Cookie Max-Age=-1`. Браузера в прогоне нет, применение `SameSite=Lax` к POST не проверялось. Confidence `low` → `minor` (adversary).
|
||||
- **Две учётные записи провайдера с одной почтой сливаются в одну нашу.** Обмен ищет по `sub`, не найдя — по почте. Требует, чтобы Authelia выдала двум субъектам один адрес; живого провайдера нет. Confidence `medium`, без оракула → выше `major` не поднимается и в основные секции не идёт (adversary).
|
||||
- **Претензия `picture` тянет до 32 МиБ на вход.** `MappedFields.AvatarURL` заставляет библиотеку скачать URL из ответа провайдера. Confidence `low` → `minor` (adversary).
|
||||
- **Анонимный `POST /api/collections/users/request-verification` заставляет сервис слать почту.** Почта не настроена, потолка запросов нет. Confidence `medium` → `minor` (adversary).
|
||||
- **Вошедший читает, правит и удаляет свою запись `users`** (системные правила `id = @request.auth.id` оставлены), тогда как `docs/security.md` утверждает, что правила коллекций пусты и отдают `403`. Своим прогоном не проверял — бюджет попытки израсходован на пункты 1–4, 6, 7 (adversary).
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **Проверка ответа судит по `w.Result()`, а не по `w.Header()`.** Ровно этим различием держался ложно зелёный тест пункта 4. Место — `docs/review.md`, «Типовые узлы», абзац про способность проверки упасть: род механизируем grep'ом и уже дал дефект.
|
||||
- **`docs/.docs.json` объявляет механизированную сверку миграций, которой нет.** Ключ `"migrations": "migrations"`, а `check_migrations` фильтрует изменённые файлы по префиксу `migrations/`; такого каталога в репозитории нет (`git ls-files | grep -c "^migrations/"` → `0`), шаги схемы лежат в `internal/adapter/repo/pocketbase/`. Шаг гейта зелен при изменённом `migrations.go` и нетронутом `docs/database.md`. Правило есть, механизации нет: `"migrations": "internal/adapter/repo/pocketbase"` либо снять пометку «механизировано» в `docs/conventions/README.md`.
|
||||
- **Откат бинаря не откатывает шаг схемы** — назвать в `CLAUDE.md`, «Необратимое», рядом с «применённой миграцией» (эксперимент ops: два прогона `New()` разных ревизий над одним каталогом, `files.file.Protected=true` сохраняется).
|
||||
- **Новый секрет `auth.client_secret` не назван ни в перечне секретных полей `docs/conventions/config.md:94-96`, ни в инварианте `CLAUDE.md`.** Перечень поимённый и закрытый — это и делает его правилом.
|
||||
- **Ввод пользователя в журнале приводится к закрытому перечню, а не пишется как есть** — обобщение приёма задачи `no-user-filename-in-log`. Повод: `providerError` из query уходит в `logger.Warn` целиком (падающий тест adversary: 204806 байт запроса → 204902 байта журнала).
|
||||
- **Имя провайдера `oidc` — это значение в связи учётной записи с провайдером.** Смена имени после выкладки отвяжет всех заведённых людей. `CLAUDE.md`, «Необратимое», знает имя ключа конфига, но не знает имени провайдера.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
### План: темы, глубины, дома
|
||||
|
||||
| тема | дом | глубина | закрыта |
|
||||
|---|---|---|---|
|
||||
| requirements | дельта-спеки `access`, `intake`, `storage` | разбор | specs |
|
||||
| autotests | `CLAUDE.md`, «Гейт» | — | autotests |
|
||||
| conventions | `docs/conventions/{config,database,errors,logging}.md` | разбор | code |
|
||||
| architecture | `docs/architecture.md`; источник `docs/passport.md` | доказательство | architecture |
|
||||
| security | `docs/security.md` | доказательство | adversary |
|
||||
| operations | `docs/architecture.md` «Эксплуатация»; источник `docs/database.md` | доказательство | ops |
|
||||
|
||||
Тем без дома нет. Тем без отчёта нет.
|
||||
|
||||
### Что запускалось и что нет
|
||||
|
||||
- Запущены на метке `large`, режим «по графу»: `specs`, `code`, `architecture`, `adversary`, `ops`, `autotests`.
|
||||
- `basics` не запускался: решение `review-scope` — своих тем сверх ядра нет. Это решение, а не бюджет.
|
||||
- Триаж запускал сам: `task gate BASE=origin/master` (exit 0), покрытие `internal/controller/http` и `internal/config`, шесть собственных тестов-оракулов через `go test -overlay=…` (в дерево проекта не писал), чтение исходников PocketBase v0.39.10 и `docs.py`.
|
||||
|
||||
### Чего запущенные проходы не могли проверить в принципе
|
||||
|
||||
- Живой вход у настоящей Authelia не воспроизводился ни одним проходом и мной: провайдера нет, поднять нечем. Всё, что известно о протоколе, получено против подставного провайдера и исходников библиотеки.
|
||||
- Поведенческая верификация живым запуском сервиса не проводилась: адаптер Telegram роняет старт при негодном токене, а боевым токеном запускаться запрещено (`CLAUDE.md`, «Запреты»).
|
||||
- Поведение браузера с куками — применение `SameSite`, приём `Set-Cookie` кросс-сайтом — не проверялось: браузера в прогоне нет.
|
||||
- Панель `/_/` в тестовом роутере отсутствует (её вешает `apis.Serve`); закрывает её обратный прокси, то есть выкладка, а она вне модели.
|
||||
- `govulncheck` дал две уязвимости (GO-2026-6061 grpc, GO-2026-5764 aws eventstream/s3), обе унаследованы от `origin/master`, в цепочке распознавания, не в этом коде.
|
||||
- Замер утечки обработчиков сделан на 50 и 3000 итерациях в тесте; поведение под настоящим потоком не замерялось ничем.
|
||||
|
||||
### Что осталось целиком на человеке
|
||||
|
||||
Из `docs/review.md`, «Недоступно проверке». Списки не сливаются: при следующем промахе первый вопрос — «не тот ли это класс, который мы перестали проверять».
|
||||
|
||||
**Не проверит ни один проход:**
|
||||
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object Storage поднять в тесте нечем;
|
||||
- `operations`: реальный профиль нагрузки — проект работает на единицах записей в день, и утверждения о росте остаются условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата отдан внешней программе, и она вне нашей границы.
|
||||
|
||||
**Перестали проверять сознательно:**
|
||||
- `autotests`: разбор вывода настоящего `ffprobe` — проверки приёма получают длительность от подставного источника; своего теста у `adapter/metaviewer/ffmpeg` нет (`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`).
|
||||
|
||||
**Сверх проектного перечня — общее:** история инцидентов, поведение под реальным потоком, поведение внешних систем в их версиях (здесь — конкретной Authelia владельца и её правила на этого клиента), завязка потребителей на текущее поведение и вопрос «а нужна ли эта функциональность вообще».
|
||||
|
||||
### Каких документов проекта не хватило
|
||||
|
||||
- `docs/review.md`, «Типовые ложноположительные»: раздел есть и непуст (4 пункта), но **ни один не относится к области этого изменения** — все четыре про конвейер, очередь и открытый HTTP. Отсев для темы входа и веб-поверхности шёл по общим критериям, проектных ложноположительных этой области я не знал.
|
||||
- `docs/review.md`, «Вопросы по темам»: вопросов по теме входа нет — раздел писан до появления этой поверхности. Вопросы `security` про журнал и метки применялись, вопросы про конвейер неприменимы.
|
||||
- `docs/conventions/web-ui.md` существует (104 строки), требование к языку пользовательского текста в нём есть; конвенции по форме HTTP-ответов входа (коды, тело отказа) в нём нет — поэтому «Login failed» судилось только по требованию русского языка, а форма ответа не судилась ничем.
|
||||
- Прочих пробелов проходы не заявляли; `CLAUDE.md` с разделом инвариантов, `docs/security.md`, `docs/architecture.md`, `docs/database.md`, `docs/passport.md` и четыре конвенции были на месте и использовались.
|
||||
|
||||
### Потолки проходов
|
||||
|
||||
- `code`: 4 из 4 — срез сработал. За срезом остались два рода, названы самим проходом: (1) язык пользовательского текста на новой публичной поверхности («Login failed», «Logout failed» по-английски против `web-ui.md`); (2) продолжение известных «Расхождений» новым кодом — `msg` предложением, «failed to» в обёртках, третья точка трансляции доменной ошибки в ответ.
|
||||
- `architecture`: 3 из 3 — потолок выбран полностью, за срезом ничего не заявлено.
|
||||
- `specs`, `adversary`, `ops`, `autotests`: **свои потолки не сообщили.** Это находка о прогоне: сколько находок каждый показал против своего лимита и что осталось за срезом, установить нечем. Из четверых пришло 5, 6, 3 и 3 находки — то есть по крайней мере `adversary` шёл близко к типичному лимиту, и молчание о срезе здесь дороже всего.
|
||||
|
||||
### Срезано потолком триажа — названо поимённо
|
||||
|
||||
Тринадцать позиций с оракулами не попали в секции 1–2 и **к правке не заказаны**. Строки ниже — не задание; они здесь потому, что ничего не выбрасывается молча.
|
||||
|
||||
1. **G** (`major`, architecture): `SessionDuration` питает две точки разной обратимости — `AuthToken.Duration` в применяемом однажды шаге схемы и `MaxAge` куки, перечитываемый каждый подъём. Первая же правка константы уедет только в куку. Оракул — инвариант `CLAUDE.md` «Миграция, уехавшая на сервер, не переписывается» и собственный довод `design.md`. Правка (перенести `AuthToken.Duration` в `ApplyProviderSettings`) стоит трёх строк **сейчас** и требует нового шага схемы **после** выкладки: окно закрывается мерджем. Первый кандидат на восьмое место.
|
||||
2. **M** (`minor`): код провайдера оседает в журнале запросов хранилища на пять суток. Проверено мной по исходникам: `activityLogger` пишет `event.Request.URL.RequestURI()` (со строкой запроса) полем `url` (`apis/middlewares.go:391,422`), ретеншен `MaxDays: 5` (`core/settings_model.go:158`), проект его не переопределяет. Спека требует «код MUST не попадать в журнал»; наш `slog` чист, и тест смотрит только в него.
|
||||
3. **N** (`minor`): аноним пишет в журнал контейнера мегабайты (`?error=<1 МиБ>` → `Warn` целиком; падающий тест adversary: 204806 → 204902 байта). Журнал — единственное место наблюдения двух инвариантов о молчаливой потере задачи.
|
||||
4. **L** (`minor`): `down202608120001` падает на валидации — проверено моим прогоном: `Save(users)` с `Duration=0` → `authToken: (duration: cannot be blank.)`. Путь «шаг обратим своим down» из `design.md` не работает, снятие `Protected` не выполняется вовсе, а сам `down` возвращает `CreateRule = ""` — открытую регистрацию, то самое, что чинит `up`.
|
||||
5. **O** (`minor`): `docs/database.md:98-101` противоречит коду («поле файла не помечено защищённым»), коллекция `users` не описана, три новых числа (7 суток, 10 минут, 15 секунд) не попали в таблицу «Настройки с числовым значением». Оракул — `docs/conventions/database.md`, «Прочее».
|
||||
6. **Q** (`minor`): уровни журнала не по адресату — отказ человека у Authelia даёт `WARN`, возврат по старой ссылке `ERROR`. Оракул — `docs/conventions/logging.md`, «Уровни», дословно. Плюс ни одна новая запись не несёт поля `capability`.
|
||||
7. **S** (`minor`): начало входа собрано руками поверх того, что библиотека экспортирует (`InitProvider`, `BuildAuthURL`, `PKCE`) — протокол разрезан пополам, `auth_url` и `client_id` получают второго потребителя мимо настроек коллекции.
|
||||
8. **U** (`minor`): пустая секция `[auth]` роняет сервис целиком, тогда как пустой токен бота лишь деградировал до «работает без Telegram». Асимметрия осознанная, но в рантбуке выкладки не названа.
|
||||
9. **V** (`minor`): новое безусловное условие отказа старта по шести ключам живёт в `tasks.md` и конвенции, но не в норме.
|
||||
10. **Поведение вне спеки** (8 пунктов от `specs`, ни один не заказан): `stateCookieMaxAge` 10 минут; редирект успешного входа на `/`; выход без сессии отвечает `200`; отказ загрузки записи при выходе оставляет куку; состав `scope`; склейка `authURL` через `?`/`&`; `ApplyProviderSettings` перетирает список провайдеров целиком (провайдер, заведённый владельцем в панели, исчезнет при подъёме); `down` не возвращает `OTP.Enabled`.
|
||||
11. **Границы спеки** (5 пунктов): два входа одновременно в двух вкладках; поведение при отказе приведения настроек провайдера; остальная поверхность аутентификации хранилища (`confirm-password-reset`, `request-verification`, `confirm-verification`, `request-email-change` — перечень «что выключено» в спеке закрыт тремя пунктами, а поверхность шире); отзыв доступа внутри срока сессии; «владелец закрывает чужие сессии немедленно» существует только как ручная правка в панели.
|
||||
12. **Имя куки состояния `transcriber_login` в спеке не нормировано**, в отличие от `transcriber_session`; **комментарий шага `up202608110001`** до сих пор утверждает «Защищённым поле не помечено намеренно» — после выкладки два шага противоречат друг другу в исходнике.
|
||||
13. **Литерал `"users"` живёт в четырёх местах** при существующих константах имён коллекций (`FilesCollection`, `JobsCollection`).
|
||||
|
||||
**Выброшено как вкусовщина (3):** имена ключей `auth.*` как таковые (`CLAUDE.md` уже относит имя ключа конфига к необратимому — повторение записанного, а не находка); замечание про JSON-404 катч-олла на `/` (поведение хранилища, не этого кода, последствие не названо); предложение обобщить сборку адреса согласия сверх пункта S (работающий частный случай, последствия сверх S нет).
|
||||
|
||||
### Четыре строки, которых не принесёт ни один проход
|
||||
|
||||
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его не открывает. Расхождение изменения с записанным решением ловит скилл `av-dev-docs:healthcheck`, а не ревью. В этом изменении решений владельца названо минимум три (архив бессрочный, петля обмена внутри процесса, семь суток сессии) — ни одно против ADR не сверено.
|
||||
2. **Записанные наблюдения проекта не использовались.** `docs/research/` прогон не открывал. Всякое число в этом отчёте снято на этом прогоне и сопровождено командой или выводом; чисел из записанных наблюдений здесь нет.
|
||||
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.** Различение «идиоматично против распространено» не спрашивает никто с тех пор, как упразднён проход про идиоматичность; к новому коду (`auth.go`, `session.go`, `provider.go`) это относится целиком.
|
||||
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.** Проход независимой реализации снят по стоимости, а не по замеру. «Не знаю, чего не знаю» про форму решения входа — а форма здесь нащупывалась по ходу, это и подняло метку до `large` — не достаёт никто.
|
||||
|
||||
Метка `large`, поэтому пятая строка (про `small`) не применяется — дома всех трёх тем `security`, `operations`, `architecture` открывались.
|
||||
@@ -0,0 +1,298 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Вход через внешнего провайдера
|
||||
|
||||
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
|
||||
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
|
||||
MUST не заводить: учётные записи держит провайдер, и это граница домена из
|
||||
паспорта.
|
||||
|
||||
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
|
||||
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
|
||||
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
|
||||
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
|
||||
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
|
||||
|
||||
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
|
||||
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
|
||||
выдавал. Без этой сверки вход принимает чужой код.
|
||||
|
||||
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
|
||||
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
|
||||
держит обработчик возврата открытым до упора, а «провайдер медленный»
|
||||
становится неотличим от «провайдер отказал».
|
||||
|
||||
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
|
||||
|
||||
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
|
||||
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
|
||||
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
|
||||
`GET` его срабатывание уносится переходом по чужой ссылке.
|
||||
|
||||
#### Scenario: Человек входит впервые
|
||||
|
||||
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
|
||||
- **WHEN** человек проходит вход и возвращается с кодом провайдера
|
||||
- **THEN** учётная запись заводится, а сессия открывается
|
||||
- **AND** дальнейший запрос к API от этой сессии проходит
|
||||
|
||||
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
|
||||
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
|
||||
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
|
||||
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
|
||||
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
|
||||
средствами хранилища его требует.
|
||||
|
||||
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
|
||||
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
|
||||
|
||||
#### Scenario: Признаки носителя состояния
|
||||
|
||||
- **WHEN** сервис уводит человека к провайдеру
|
||||
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
|
||||
что и кука сессии
|
||||
|
||||
#### Scenario: Возврат нельзя переиграть
|
||||
|
||||
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
|
||||
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
|
||||
#### Scenario: Возврат с чужим состоянием
|
||||
|
||||
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
|
||||
выдавал
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
- **AND** учётная запись не заводится
|
||||
|
||||
#### Scenario: Провайдер отказал
|
||||
|
||||
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
|
||||
### Requirement: Иных способов открыть сессию нет
|
||||
|
||||
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
|
||||
запись и получить сессию. Собственное создание записи в коллекции пользователей,
|
||||
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
|
||||
выключены настройкой коллекции.
|
||||
|
||||
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
|
||||
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
|
||||
хранилища заводит коллекцию пользователей с открытым созданием записи и
|
||||
включённым входом по паролю, и без этого требования закрытие приёма обходится
|
||||
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
|
||||
|
||||
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
|
||||
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
|
||||
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
|
||||
настоящему входу с этим адресом.
|
||||
|
||||
#### Scenario: Завести учётную запись самому нельзя
|
||||
|
||||
- **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 читаться как ключ к чужому
|
||||
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
|
||||
собранные логи, откуда её не убрать.
|
||||
|
||||
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
|
||||
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
|
||||
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
|
||||
|
||||
#### Scenario: Значения сессии нет в журнале
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он шлёт запрос к API с этой кукой
|
||||
- **THEN** значение сессии не встречается ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Адреса почты нет в журнале
|
||||
|
||||
- **WHEN** человек проходит вход и учётная запись заводится
|
||||
- **THEN** адрес его почты не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Сессия переживает перезапуск сервиса
|
||||
|
||||
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
|
||||
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
|
||||
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
|
||||
|
||||
#### Scenario: Прежняя кука годна после перезапуска
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** сервис поднимается заново на том же хранилище
|
||||
- **THEN** запрос с прежней кукой проходит
|
||||
|
||||
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
|
||||
|
||||
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, числом в настройке
|
||||
коллекции и тем же числом в сроке жизни куки. Умолчание хранилища MUST не
|
||||
применяться: оно даёт пять суток, и это число никем не выбрано.
|
||||
|
||||
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
|
||||
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
|
||||
человек, которому Authelia закрыла доступ, работает до истечения своей сессии.
|
||||
Паспорт опирается на отзыв в Authelia как на способ остановить того, кто
|
||||
тратит слишком много, — значит срок сессии и есть цена этой остановки.
|
||||
|
||||
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
|
||||
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
|
||||
угодно раз, никуда не входя. Сервис 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 приводиться к значениям конфига при каждом запуске,
|
||||
а не заводиться однажды шагом схемы.
|
||||
|
||||
Причина второго требования в необратимости шага схемы: применённый шаг не
|
||||
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
|
||||
вовсе — вход сломался бы после ротации.
|
||||
|
||||
#### Scenario: Секрета нет в журнале
|
||||
|
||||
- **WHEN** сервис поднимается с настроенным провайдером
|
||||
- **THEN** значение секрета не встречается ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Смена секрета доезжает до хранилища
|
||||
|
||||
- **GIVEN** сервис уже поднимался с прежним секретом
|
||||
- **WHEN** секрет в конфиге заменён и сервис поднят заново
|
||||
- **THEN** настройки провайдера в хранилище несут новое значение
|
||||
@@ -0,0 +1,104 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
|
||||
сохранена и получить заведённую под неё задачу расшифровки в состоянии
|
||||
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
|
||||
полем `status`.
|
||||
|
||||
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||
|
||||
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
||||
и переименование поля ломает внешнюю программу молча. Появление отказа без
|
||||
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
|
||||
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
пригодность содержимого узнаёт у источника метаданных.
|
||||
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
|
||||
видно было анонимно.
|
||||
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||
со значением `created`
|
||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** ни файла, ни задачи не заводится
|
||||
- **AND** тело ответа не несёт данных задачи
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||
- **AND** ни файла, ни задачи не заводится
|
||||
|
||||
#### Scenario: Размеру записи приём не судья
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт запись нулевой длины
|
||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||
|
||||
### Requirement: Опрос готовности задачи
|
||||
|
||||
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||||
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
|
||||
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
|
||||
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
|
||||
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
|
||||
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
|
||||
пока текста нет: пустая строка на месте отсутствующего текста читается как
|
||||
«расшифровка пуста».
|
||||
|
||||
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
|
||||
кодам ответа перебирается список заведённых задач.
|
||||
|
||||
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
|
||||
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
|
||||
|
||||
#### Scenario: Задача найдена
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает состояние заведённой задачи
|
||||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
|
||||
|
||||
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
|
||||
|
||||
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
|
||||
состояние по неизвестному идентификатору
|
||||
- **THEN** оба ответа имеют код `401`
|
||||
|
||||
#### Scenario: Расшифровки ещё нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
||||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||
|
||||
#### Scenario: Задачи с таким идентификатором нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
||||
@@ -0,0 +1,77 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Файл отдаётся ссылкой
|
||||
|
||||
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||||
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||||
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||||
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||||
|
||||
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
||||
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
|
||||
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
|
||||
здесь нет — его заводит отдельная задача.
|
||||
|
||||
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
||||
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
||||
недосмотр.
|
||||
|
||||
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||||
|
||||
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
|
||||
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
|
||||
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
|
||||
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||||
бессрочно.
|
||||
|
||||
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||||
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
|
||||
половину ключа.
|
||||
|
||||
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||||
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||||
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||||
другое кончается в журнале и собирает ссылку не хуже успешного пути.
|
||||
|
||||
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||||
хранилища, а не по ссылке.
|
||||
|
||||
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
|
||||
`intake`.
|
||||
|
||||
#### Scenario: Файл забирают по ссылке
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **AND** забирающий предъявил сессию и взял по ней токен файла
|
||||
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||
|
||||
#### Scenario: Без сессии файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **WHEN** ссылку на файл запрашивают без сессии
|
||||
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||
|
||||
#### Scenario: Ссылка ведёт в никуда
|
||||
|
||||
- **WHEN** запрашивают ссылку на запись, которой нет
|
||||
- **THEN** приходит отказ, а не пустой ответ
|
||||
|
||||
#### Scenario: По журналу ссылку не собрать
|
||||
|
||||
- **GIVEN** запись принята и прошла конвейер
|
||||
- **WHEN** читают журнал сервиса целиком
|
||||
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
|
||||
|
||||
#### Scenario: Отказ чтения файла не называет его ключ
|
||||
|
||||
- **GIVEN** файл записи не читается из хранилища
|
||||
- **WHEN** шаг конвейера берётся за эту запись и отказывает
|
||||
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
|
||||
|
||||
#### Scenario: Конвейер читает файл без сессии
|
||||
|
||||
- **GIVEN** запись принята и ждёт расшифровки
|
||||
- **WHEN** шаг конвейера берётся за неё
|
||||
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||||
@@ -0,0 +1,121 @@
|
||||
## 1. Конфигурация
|
||||
|
||||
- [x] 1.1 Завести секцию конфига под провайдера: адрес авторизации, адрес обмена
|
||||
кода, адрес сведений о пользователе, идентификатор клиента, секрет клиента,
|
||||
адрес возврата
|
||||
- [x] 1.2 Дописать те же ключи в `config.dist.toml` с пустыми значениями и
|
||||
комментарием, откуда их брать
|
||||
- [x] 1.3 Проверить, что незаполненный конфиг роняет старт с внятным
|
||||
сообщением, а не поднимает сервис с молча выключенным входом
|
||||
|
||||
## 2. Провайдер в хранилище
|
||||
|
||||
- [x] 2.1 Завести шаг схемы, включающий провайдера `oidc` у коллекции
|
||||
пользователей; файл шага именуется по правилу проекта и не переписывает
|
||||
прежние
|
||||
- [x] 2.2 Тем же шагом закрыть создание записи в коллекции пользователей и
|
||||
выключить вход по паролю, одноразовый код и восстановление доступа: умолчание
|
||||
библиотеки оставляет их открытыми
|
||||
- [x] 2.3 Тем же шагом назначить срок жизни сессии числом вместо умолчания в
|
||||
пять суток
|
||||
- [x] 2.4 При подъёме сервиса приводить настройки провайдера к значениям
|
||||
конфига: адреса, идентификатор клиента, секрет
|
||||
- [x] 2.5 Убедиться, что секрет не попадает в журнал ни при подъёме, ни при
|
||||
ошибке настройки
|
||||
|
||||
## 3. Вход, возврат, выход
|
||||
|
||||
- [x] 3.1 `GET /auth/login`: завести состояние и проверочный код PKCE, положить
|
||||
во временную куку с теми же признаками, что у сессионной, увести на адрес
|
||||
авторизации провайдера
|
||||
- [x] 3.2 `GET /auth/callback`: сверить состояние с выданным, отвергнуть
|
||||
несовпавшее и уже употреблённое, обменять код средствами хранилища с
|
||||
таймаутом, поставить куку сессии, убрать временную
|
||||
- [x] 3.3 Кука сессии зовётся `transcriber_session` и несёт `HttpOnly`,
|
||||
`SameSite` и `Secure`; последний берётся из конфига с умолчанием «включено»
|
||||
- [x] 3.4 `POST /auth/logout`: сперва обесценить ключ токенов учётной записи,
|
||||
затем убрать куку сессии
|
||||
- [x] 3.5 Промежуточный слой перекладывает значение куки в заголовок
|
||||
`Authorization`, только когда заголовка нет, и только на адресах приложения
|
||||
|
||||
## 4. Закрытие API
|
||||
|
||||
- [x] 4.1 `POST /api/audio` и `GET /api/status/{id}` требуют узнанного
|
||||
отправителя; отказ — код `401`
|
||||
- [x] 4.2 Отказ по отсутствию сессии наступает раньше чтения тела запроса
|
||||
- [x] 4.3 `GET /health` и `GET /metrics` остаются доступны без сессии
|
||||
- [x] 4.4 Отказ без сессии одинаков для заведённой и неизвестной задачи
|
||||
- [x] 4.5 Пометить поле файла защищённым тем же шагом схемы: ссылка на файл
|
||||
перестаёт быть правом пройти по ней и требует сессии
|
||||
- [x] 4.6 Убедиться, что конвейер по-прежнему читает файл из файловой системы, а
|
||||
панель администратора его по-прежнему скачивает
|
||||
|
||||
## 5. Проверки
|
||||
|
||||
- [x] 5.1 Тест: оба эндпоинта API без куки отдают `401` и не заводят задачу;
|
||||
`/health` и `/metrics` без куки отдают `200`
|
||||
- [x] 5.2 Тест: запрос с прежней кукой проходит после пересоздания сервера
|
||||
- [x] 5.3 Тест: после выхода запрос с прежней кукой получает отказ
|
||||
- [x] 5.4 Тест: ни значение секрета, ни значение сессии, ни адрес почты не
|
||||
встречаются в записанном выводе логгера
|
||||
- [x] 5.5 Тест: возврат с невыданным состоянием не открывает сессию и не заводит
|
||||
учётную запись; повторный возврат с уже употреблённым — тоже
|
||||
- [x] 5.6 Тест: анонимное создание записи в коллекции пользователей и вход по
|
||||
паролю получают отказ
|
||||
- [x] 5.7 Тест: запрос с кукой и заголовком разом проходит по заголовку
|
||||
- [x] 5.8 Тест: ссылка на файл записи без сессии отдаёт отказ, а с сессией —
|
||||
тот же файл
|
||||
- [x] 5.9 `task gate` зелёный целиком
|
||||
|
||||
## 6. Документация
|
||||
|
||||
- [x] 6.1 `docs/security.md`: первая строка периметра переписана под новый
|
||||
периметр; названо новое место жизни секрета клиента — база; в разделе «Что
|
||||
разграничивает доступ» записано, что допуск держит правило провайдера вне
|
||||
репозитория, а сервис своей проверки не делает
|
||||
- [x] 6.2 `docs/architecture.md`: capability `access` внесена в перечень
|
||||
- [x] 6.3 `docs/conventions/config.md`: новые ключи конфига и расхождения
|
||||
образца, если появились
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
Перенесены из записи задачи `oidc-login` дословно. Файл задачи закрытие удалит —
|
||||
критерии обязаны его пережить.
|
||||
|
||||
- Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а
|
||||
не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без
|
||||
куки: код ответа 401 либо 302 на вход, тело без данных задачи. Тот же тест
|
||||
проверяет вторую сторону границы: `GET /health` и `GET /metrics` без куки
|
||||
отвечают 200.
|
||||
- Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой
|
||||
после пересоздания сервера проходит.
|
||||
- Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос
|
||||
получает отказ.
|
||||
- Секрет провайдера не попадает ни в лог, ни в ответ. Оракул — тест на отсутствие
|
||||
значения секрета в записанном выводе логгера.
|
||||
- Первая строка `docs/security.md` описывает новый периметр. Оракул — `task
|
||||
gate`, шаг `docs.py check`.
|
||||
|
||||
**Сужение против исходного критерия, объявленное ревью дизайна:** код отказа —
|
||||
`401`, без допуска `302`. Оба адреса судят внешнюю программу, а не браузер, и
|
||||
`302` для программы означает «получил 200 со страницей входа»; `curl -L` при нём
|
||||
уходит постить тело на страницу входа провайдера. Дельта-спека `intake`
|
||||
нормирует `401` двумя сценариями.
|
||||
|
||||
## Рубрика ревью дизайна
|
||||
|
||||
Порождена проходом `rubric` до чтения артефактов; сюда переносятся пункты,
|
||||
ставшие приёмочными сверх критериев задачи.
|
||||
|
||||
- Отказ без сессии наступает раньше чтения тела и раньше обращения к хранилищу.
|
||||
- Форма отказа одна и та же у существующего и несуществующего ресурса.
|
||||
- Ни одно значение, дающее доступ, не печатается: код провайдера, секрет
|
||||
клиента, значение сессии, адрес почты.
|
||||
- Правило доступа читается как «всё требует сессии, кроме перечня», а перечень
|
||||
открытого живёт в одном месте.
|
||||
- Все прочие способы получить сессию к тому же субъекту выключены либо названы
|
||||
поимённо с обоснованием, почему они не обход.
|
||||
- Возврат от провайдера отвергается без состояния, с чужим, с истёкшим и с уже
|
||||
употреблённым — до обмена кода.
|
||||
- У обращения к провайдеру есть таймаут, и «медленный» отличается от «отказал».
|
||||
- Исход входа и выхода не зависит от порядка параллельных операций.
|
||||
Reference in New Issue
Block a user