вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
@@ -36,15 +36,14 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
||||
|
||||
Что нарушать нельзя.
|
||||
|
||||
- **Секрет не покидает конфиг.** Ключ SpeechKit, пара ключей Object
|
||||
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
|
||||
- **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object
|
||||
Storage не попадают в git, в лог, в ответ пользователю и
|
||||
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
|
||||
вручную во всех местах выкладки. **critical**
|
||||
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
|
||||
пользователей хранилища — туда его кладёт приведение настроек при каждом
|
||||
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
|
||||
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
|
||||
места запрета это не отменяет.
|
||||
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
|
||||
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
|
||||
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
|
||||
на что. Чтение файла базы больше не равносильно чтению секрета.
|
||||
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
||||
пользователя и его сообщение в лог не пишутся — только длина и
|
||||
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
||||
@@ -120,7 +119,7 @@ go vet ./...
|
||||
gofmt -l .
|
||||
golangci-lint run
|
||||
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
|
||||
go run ./cmd/oidcstub # подставной провайдер OIDC для локального входа
|
||||
go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально
|
||||
task front # приложение: зависимости, Biome, юнит-тесты, сборка
|
||||
task image # docker-образ; тег и раскладка — docs/architecture.md
|
||||
task gate # весь набор проверок разом
|
||||
@@ -211,9 +210,16 @@ Node на машину **не ставится**: шаг сборки прило
|
||||
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
|
||||
Списывать отказ на долг больше нельзя: списывать не на что.
|
||||
|
||||
Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
|
||||
Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
|
||||
новый:
|
||||
|
||||
- `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id
|
||||
may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`,
|
||||
а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением
|
||||
владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`.
|
||||
Владельца файлов в смонтированном каталоге это не двигает, потому что те же
|
||||
числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`);
|
||||
|
||||
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
|
||||
сравнения ошибок приведением типа. Закрыто задачей
|
||||
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
|
||||
@@ -230,17 +236,18 @@ Node на машину **не ставится**: шаг сборки прило
|
||||
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
|
||||
его ронять и пересоздавать можно свободно.
|
||||
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
|
||||
проверяются на старте, но наружу при этом не обращаются, так что годятся
|
||||
выдуманные непустые значения — адреса `[auth]` должны лишь разбираться как
|
||||
ссылки. Расшифровка при выдуманных ключах не работает: её подменяют
|
||||
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
|
||||
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
|
||||
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
|
||||
`internal/adapter/recognizer/memory.go`. Подробности строками в
|
||||
`config.example.toml`.
|
||||
**Войти при выдуманных адресах нельзя** — они никуда не ведут, а сервис без
|
||||
входа не отдаёт ничего. Вместо провайдера поднимается заглушка
|
||||
`cmd/oidcstub` — она отвечает на `/authorize`, `/token` и `/userinfo`, а
|
||||
проверок не делает никаких;
|
||||
значения `[auth]` под неё стоят строками в `config.example.toml`. Ключи
|
||||
боевого провайдера на машине разработчика при этом по-прежнему не нужны.
|
||||
**На машине без прокси представиться нечем**: сервис узнаёт
|
||||
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
|
||||
не ставит. На место контура встаёт подставной прокси —
|
||||
`go run ./cmd/devtools proxy`: он слушает свой порт, ставит заголовок и
|
||||
переправляет запрос сервису, а проверок не делает никаких. Приложение при этом
|
||||
открывают **по адресу прокси**, а не по адресу сервиса. Ключей боевого
|
||||
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
|
||||
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
||||
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
||||
подставляй `internal/adapter/recognizer/memory.go`.
|
||||
|
||||
+8
-3
@@ -46,8 +46,8 @@ COPY . .
|
||||
COPY --from=front-build /web/embed/dist ./web/embed/dist
|
||||
|
||||
# Build the application
|
||||
# Собирается одна точка входа из cmd/, а не весь пакет: соседний cmd/oidcstub —
|
||||
# подставной провайдер для локального запуска, и в образе ему делать нечего.
|
||||
# Собирается одна точка входа из cmd/, а не весь пакет: соседний cmd/devtools —
|
||||
# оснастка разработчика (подставной прокси), и в образе ей делать нечего.
|
||||
RUN CGO_ENABLED=0 go build -o transcriber ./cmd/transcriber
|
||||
|
||||
# ----------------
|
||||
@@ -91,7 +91,12 @@ COPY docker/entrypoint.sh /usr/bin/entrypoint
|
||||
RUN chmod 755 /usr/bin/entrypoint
|
||||
|
||||
# Set user
|
||||
USER transcriber
|
||||
#
|
||||
# Числом, а не именем: имя разрешает в идентификатор сам образ, и хост, которому
|
||||
# нужно понять владельца файлов в смонтированном каталоге, разрешить его не
|
||||
# может. Числа те же, что заданы выше при заведении пользователя (`-u 1000`,
|
||||
# `-g 1000`), поэтому владелец файлов не меняется — меняется только запись.
|
||||
USER 1000:1000
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -41,18 +41,22 @@
|
||||
|
||||
### Кого пускают
|
||||
|
||||
Приём и опрос закрыты сессией OIDC. Что её выдаёт и чем она предъявляется —
|
||||
Кто пришёл, сервис узнаёт из заголовка `Remote-User`, который ставит обратный
|
||||
прокси, сходив к Authelia; своего входа у сервиса нет. Кому верить, задаёт
|
||||
перечень доверенных адресов в секции `[auth]`. Подробности —
|
||||
[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
|
||||
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
|
||||
|
||||
Локально провайдера нет, и войти при выдуманных адресах нельзя — вместо него
|
||||
поднимается заглушка:
|
||||
Локально прокси нет, а браузер заголовков не ставит — на место контура встаёт
|
||||
подставной прокси из оснастки:
|
||||
|
||||
```bash
|
||||
go run ./cmd/oidcstub
|
||||
go run ./cmd/devtools proxy
|
||||
```
|
||||
|
||||
Значения `[auth]` под неё стоят строками в `config.example.toml`.
|
||||
Приложение после этого открывают **по адресу прокси** — `http://localhost:9000`.
|
||||
В перечне доверенных адресов при этом должен стоять петлевой; строки под это
|
||||
стоят в `config.example.toml`.
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -68,12 +72,15 @@ inv pl -- transcriber
|
||||
|
||||
## HTTP API
|
||||
|
||||
Семь адресов приложения: `POST /api/audio` — приём записи, `GET /api/status/:id`
|
||||
— готовность задачи, `GET /auth/login`, `GET /auth/callback` и
|
||||
`POST /auth/logout` — вход через провайдера
|
||||
([access](openspec/specs/access/spec.md)), `GET /metrics` — метрики Prometheus с
|
||||
префиксом `transcriber_`, `GET /health` — проверка живости. Сверх них тем же
|
||||
портом отдаётся собственная поверхность встроенного хранилища и панель `/_/` —
|
||||
Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
|
||||
записи, `GET /app/audiorecords` — страница своих записей,
|
||||
`GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
|
||||
текст названного вида, `GET /app/me` — кто пришёл, `GET /app/config` — пределы,
|
||||
которые сервис объявляет приложению. Отдельными адресами стоят `GET /metrics` —
|
||||
метрики Prometheus с префиксом `transcriber_` — и `GET /health` — проверка
|
||||
живости. Своего входа у сервиса нет: кто пришёл, называет заголовок обратного
|
||||
прокси ([access](openspec/specs/access/spec.md)). Сверх этого тем же портом
|
||||
отдаётся собственная поверхность встроенного хранилища и панель `/_/` —
|
||||
[docs/security.md](docs/security.md), «Из чего строятся пути и ключи».
|
||||
|
||||
Контракт приёма и опроса нормативен и живёт в
|
||||
@@ -93,7 +100,7 @@ inv pl -- transcriber
|
||||
transcriber/
|
||||
├── cmd/
|
||||
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
|
||||
│ └── oidcstub/ # Подставной провайдер OIDC для локального входа
|
||||
│ └── devtools/ # Оснастка разработчика: подставной прокси для локального входа
|
||||
├── internal/
|
||||
│ ├── entity/ # Модели: задача, файл, результат распознавания
|
||||
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Command devtools — оснастка разработчика: то, что нужно для локального
|
||||
// прогона и никогда не едет в боевой образ.
|
||||
//
|
||||
// Пакет один на все такие инструменты, а не по пакету на инструмент. Причина
|
||||
// счётная: каждый отдельный пакет стоит четырёх мест — строка сборки образа,
|
||||
// «Деплой» в устройстве, «Команды» в памятке, README, — и забытая строка сборки
|
||||
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
|
||||
//
|
||||
// Сегодня подкоманда одна — `proxy`. Заведение владельца панели придёт
|
||||
// подкомандой `admin` вместе с задачей о запуске одной командой.
|
||||
//
|
||||
// Имена заголовков берутся **константами транспорта**, а не литералами: они
|
||||
// нормативны, и второй список разошёлся бы с первым молча — локальный вход
|
||||
// перестал бы узнавать кого бы то ни было, а искали бы поломку в сервисе.
|
||||
//
|
||||
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
|
||||
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
|
||||
//
|
||||
// В образ пакет не едет: ступень сборки называет `./cmd/transcriber` поимённо.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "proxy":
|
||||
runProxy(os.Args[2:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `Оснастка разработчика.
|
||||
|
||||
Подкоманды:
|
||||
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
|
||||
|
||||
`)
|
||||
}
|
||||
|
||||
// runProxy поднимает подставной обратный прокси.
|
||||
//
|
||||
// Зачем он нужен: сервис узнаёт пришедшего по заголовку, который на сервере
|
||||
// ставит Caddy, сходив к Authelia. На машине разработчика ни того ни другого
|
||||
// нет, а браузер заголовков не ставит — значит приложение локально не
|
||||
// открывалось бы вовсе. Прокси встаёт на их место: слушает свой порт, ставит
|
||||
// заголовок и переправляет запрос сервису.
|
||||
//
|
||||
// Проверок он не делает никаких — ни пароля, ни группы, ни срока. Это его
|
||||
// назначение, а не упущение: пускать он должен всякого, кто до него дошёл, а
|
||||
// сам он слушает петлевой адрес.
|
||||
func runProxy(args []string) {
|
||||
flags := flag.NewFlagSet("proxy", flag.ExitOnError)
|
||||
listen := flags.String("listen", "127.0.0.1:9000", "адрес, на котором слушать")
|
||||
target := flags.String("target", "http://127.0.0.1:8080", "адрес сервиса")
|
||||
login := flags.String("user", "dev", "логин, которым называть пришедшего")
|
||||
name := flags.String("name", "Разработчик", "имя, пригодное к показу")
|
||||
email := flags.String("email", "", "адрес почты; пустой не ставится вовсе")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Слушаем петлевой адрес по умолчанию, и это часть назначения: прокси
|
||||
// пускает всякого, кто до него дошёл, и в чужой сети становится открытым
|
||||
// входом в сервис.
|
||||
upstream, err := url.Parse(*target)
|
||||
if err != nil {
|
||||
log.Fatalf("адрес сервиса не читается: %v", err)
|
||||
}
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(upstream)
|
||||
|
||||
// Именно Set, а не Add. Прокси, который **добавляет** заголовок к
|
||||
// присланному, оставляет рядом со своим значением чужое — и сервис
|
||||
// отвергает такой запрос целиком, потому что двух значений он не
|
||||
// разбирает. Настоящий Caddy обязан делать то же самое, и это
|
||||
// записано требованием к контуру в модели угроз.
|
||||
r.Out.Header.Set(httpcontroller.LoginHeader, *login)
|
||||
r.Out.Header.Set(httpcontroller.NameHeader, *name)
|
||||
if *email != "" {
|
||||
r.Out.Header.Set(httpcontroller.EmailHeader, *email)
|
||||
} else {
|
||||
r.Out.Header.Del(httpcontroller.EmailHeader)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
log.Printf("подставной прокси: %s → %s, пришедший — %q", *listen, *target, *login)
|
||||
log.Printf("приложение открывать по http://%s", *listen)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: proxy,
|
||||
// Таймаута чтения нет намеренно: сервис принимает записи на несколько
|
||||
// часов, и прокси, обрывающий такую загрузку, ловил бы разработчика на
|
||||
// поломке, которой в сервисе нет.
|
||||
ReadTimeout: 0,
|
||||
}
|
||||
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatalf("подставной прокси остановлен: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// Команда oidcstub — подставной провайдер OIDC для локального запуска.
|
||||
//
|
||||
// Сервис держит вход через внешнего провайдера, и без него в приложение не
|
||||
// попасть вовсе: секция `[auth]` обязательна, а выдуманные адреса разбираются
|
||||
// как ссылки, но никуда не ведут. Настоящая Authelia — отдельный сервис, который
|
||||
// надо развернуть и настроить, а ключи боевого провайдера на машине разработчика
|
||||
// лежать не должны.
|
||||
//
|
||||
// Заглушка отвечает на `/authorize`, `/token` и `/userinfo`, и этого хватает:
|
||||
// `user_info_url` — обязательный ключ конфига, а при заполненном адресе сведений
|
||||
// библиотека хранилища берёт их обычным запросом с предъявленным токеном и
|
||||
// `id_token` не смотрит вовсе. Поэтому здесь нет ни ключей подписи, ни документа
|
||||
// обнаружения.
|
||||
//
|
||||
// Проверок она не делает никаких — ни секрета клиента, ни проверочного кода
|
||||
// PKCE, ни выданного токена. Вход у неё один и заранее известный: она нужна,
|
||||
// чтобы дойти до куки сессии, а не чтобы изображать провайдера. По той же
|
||||
// причине слушает она только петлевой адрес.
|
||||
//
|
||||
// Своих тестов у заглушки нет, и это решение владельца от 2026-08-15, а не
|
||||
// недосмотр. Разбор `redirect_uri` и выбор метода машиной поэтому не
|
||||
// проверяются: регресс в них ловится руками, на первом же локальном входе.
|
||||
// Довод — цена отказа: в образ этот пакет не едет, читатель у него один, а сам
|
||||
// отказ громкий и виден сразу. Путь входа при этом проверен и без неё — своим
|
||||
// подставным провайдером в `internal/controller/http/login_test.go`.
|
||||
//
|
||||
// Запуск:
|
||||
//
|
||||
// go run ./cmd/oidcstub
|
||||
// go run ./cmd/oidcstub -sub local-2
|
||||
//
|
||||
// Другое значение `-sub` заводит второго вошедшего, и почта переезжает вместе с
|
||||
// ним: без своей почты второй вход отвергается вовсе. Хранилище ищет учётную
|
||||
// запись сперва по признаку провайдера, а не найдя — по адресу почты, и на
|
||||
// найденной записи признак уникален. Второй `sub` при общей почте пришёл бы к той
|
||||
// же записи и получил бы отказ уникальности — вход отвечал бы 401, а причина
|
||||
// осталась бы в журнале хранилища строкой про связь.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// authCode — код, который заглушка отдаёт на возврате. Значение постоянное:
|
||||
// одноразовость кода держит провайдер, а здесь её изображать не для кого.
|
||||
const authCode = "local-code"
|
||||
|
||||
// accessToken — то, что уезжает в обмен и приходит обратно заголовком запроса
|
||||
// сведений о вошедшем. Заглушка его не сверяет.
|
||||
const accessToken = "local-token"
|
||||
|
||||
// readHeaderTimeout — потолок чтения заголовков. Заглушка стоит на петлевом
|
||||
// адресе, но сервер без единого срока держит подвисшее соединение вечно.
|
||||
const readHeaderTimeout = 5 * time.Second
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 9000, "Порт заглушки")
|
||||
sub := flag.String("sub", "local-1", "Идентификатор вошедшего: разные значения дают разных владельцев записей")
|
||||
name := flag.String("name", "Локальный", "Имя вошедшего")
|
||||
email := flag.String("email", "", "Почта вошедшего; пустое значение даёт <sub>@example.com")
|
||||
flag.Parse()
|
||||
|
||||
// Почта выводится из `sub`, а не стоит своим умолчанием: общая почта у двух
|
||||
// разных `sub` приводит второй вход к первой записи, а признак провайдера на
|
||||
// ней уникален — второй вошедший получал бы 401 вместо своей учётной записи.
|
||||
// Обещание «разные `-sub` дают разных владельцев» держится этой строкой.
|
||||
if *email == "" {
|
||||
*email = *sub + "@example.com"
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/authorize", authorize(logger))
|
||||
mux.HandleFunc("/token", token(logger))
|
||||
mux.HandleFunc("/userinfo", userInfo(logger, *sub, *name, *email))
|
||||
|
||||
// Петлевой адрес, а не все — заглушка пускает кого угодно кем угодно, и в
|
||||
// чужой сети это дыра, а не удобство.
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", *port)
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
}
|
||||
|
||||
logger.Info("oidc stub started", "addr", addr, "sub", *sub)
|
||||
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
logger.Error("oidc stub stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// authorize уводит браузер обратно на адрес возврата с готовым кодом.
|
||||
//
|
||||
// Настоящий провайдер спросил бы здесь имя и пароль; заглушка не спрашивает
|
||||
// ничего и возвращает сразу — в этом вся её работа.
|
||||
func authorize(logger *slog.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
target, err := url.Parse(query.Get("redirect_uri"))
|
||||
if err != nil || target.Host == "" {
|
||||
logger.Error("authorize rejected", "reason", "redirect_uri is missing or malformed")
|
||||
http.Error(w, "redirect_uri не задан или не разбирается", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Состояние возвращается тем же значением, каким пришло: сервис сверяет
|
||||
// его со своей кукой и без совпадения отвергает возврат.
|
||||
back := target.Query()
|
||||
back.Set("code", authCode)
|
||||
if state := query.Get("state"); state != "" {
|
||||
back.Set("state", state)
|
||||
}
|
||||
target.RawQuery = back.Encode()
|
||||
|
||||
logger.Info("authorize passed", "redirect_host", target.Host)
|
||||
|
||||
http.Redirect(w, r, target.String(), http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// token отдаёт токен в обмен на код. Ни код, ни секрет клиента, ни проверочный
|
||||
// код PKCE не сверяются.
|
||||
func token(logger *slog.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "обмен идёт методом POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// `id_token` не отдаётся намеренно: подписать его нечем, а библиотека
|
||||
// хранилища его и не смотрит — `user_info_url` стоит в перечне
|
||||
// обязательных ключей конфига, и без него сервис не поднимается вовсе.
|
||||
writeJSON(logger, w, map[string]any{
|
||||
"access_token": accessToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// userInfo отдаёт сведения о вошедшем.
|
||||
//
|
||||
// `email_verified` обязано быть истинным: без него библиотека адрес почты в
|
||||
// учётную запись не запишет, и запись заведётся без него.
|
||||
func userInfo(logger *slog.Logger, sub, name, email string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(logger, w, map[string]any{
|
||||
"sub": sub,
|
||||
"name": name,
|
||||
"preferred_username": sub,
|
||||
"email": email,
|
||||
"email_verified": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON отвечает разметкой JSON. Отказ записи идёт в журнал: ответ к этому
|
||||
// моменту уже начат, и сказать о нём спрашивающему нечем.
|
||||
func writeJSON(logger *slog.Logger, w http.ResponseWriter, body map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
logger.Error("response write failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ func journalMounts() []httpcontroller.Mount {
|
||||
{Path: httpcontroller.StorageRoot},
|
||||
{Path: httpcontroller.PanelRoot},
|
||||
{Path: httpcontroller.AppRoot},
|
||||
{Path: httpcontroller.AuthRoot},
|
||||
{Path: httpcontroller.HealthPath, Exact: true},
|
||||
{Path: httpcontroller.MetricsPath, Exact: true},
|
||||
}
|
||||
|
||||
+30
-22
@@ -53,13 +53,26 @@ func main() {
|
||||
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
||||
}
|
||||
|
||||
// Незаполненный вход роняет старт: подняться с молча выключенным входом
|
||||
// значит остаться открытым наружу, и узнать об этом было бы неоткуда.
|
||||
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
|
||||
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
|
||||
// этом было бы неоткуда.
|
||||
if err := cfg.Auth.Validate(); err != nil {
|
||||
logger.Error("Unable to start with incomplete login settings", "error", err)
|
||||
logger.Error("Unable to start without trusted proxies", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
|
||||
// значило бы платить за настройку, которая не меняется.
|
||||
trustedNetworks, err := cfg.Auth.TrustedNetworks()
|
||||
if err != nil {
|
||||
logger.Error("Unable to read trusted proxies", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
|
||||
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
||||
// доходит вовсе, — а это разные поломки в разных местах.
|
||||
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
|
||||
|
||||
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
|
||||
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
|
||||
// ней значит остановить всякую запись первым же захватом.
|
||||
@@ -161,18 +174,12 @@ func main() {
|
||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||
// и второму серверу на нём взяться неоткуда.
|
||||
appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger)
|
||||
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
|
||||
AuthURL: cfg.Auth.AuthURL,
|
||||
RedirectURL: cfg.Auth.RedirectURL,
|
||||
ClientID: cfg.Auth.ClientID,
|
||||
SecureCookie: cfg.Auth.SecureCookie,
|
||||
}, logger)
|
||||
|
||||
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
|
||||
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
|
||||
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
|
||||
// наблюдения и правило неизвестного пути у раздачи приложения.
|
||||
mounts := httpcontroller.ServiceMounts(appHandler, authHandler, promhttp.Handler())
|
||||
mounts := httpcontroller.ServiceMounts(appHandler, promhttp.Handler())
|
||||
|
||||
dist, appBuilt := web.Dist()
|
||||
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger)
|
||||
@@ -223,18 +230,12 @@ func main() {
|
||||
return err
|
||||
})
|
||||
|
||||
// Настройки провайдера приводятся к конфигу при каждом подъёме:
|
||||
// применённый шаг схемы не переписывается, и секрет, положенный
|
||||
// однажды шагом, не пережил бы ротации.
|
||||
if err := pbrepo.ApplyProviderSettings(storage, pbrepo.ProviderSettings{
|
||||
AuthURL: cfg.Auth.AuthURL,
|
||||
TokenURL: cfg.Auth.TokenURL,
|
||||
UserInfoURL: cfg.Auth.UserInfoURL,
|
||||
ClientID: cfg.Auth.ClientID,
|
||||
ClientSecret: cfg.Auth.ClientSecret,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to apply provider settings: %w", err)
|
||||
}
|
||||
// Слой узнавания вешается на **корневой** роутер, а не под корнем
|
||||
// приложения: он накрывает ещё и адрес выдачи файлового токена, который
|
||||
// принадлежит роутеру хранилища и группой не накрывается. Область его
|
||||
// действия при этом выводится из перечня адресного пространства — см.
|
||||
// `underIdentifiedArea`, — а не из места привязки.
|
||||
se.Router.Bind(httpcontroller.TrustedHeaderIdentity(storage, mounts, trustedNetworks, logger))
|
||||
|
||||
// Своё правило ограничителя частоты под корень приложения. Правило
|
||||
// хранилища настроено на его собственный корень и наших адресов больше не
|
||||
@@ -244,6 +245,13 @@ func main() {
|
||||
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
||||
}
|
||||
|
||||
// Хранилищу называется заголовок, из которого брать адрес
|
||||
// спрашивающего. Без этого счётчик ограничителя ключуется адресом пира,
|
||||
// а пир теперь всегда один — прокси, и бюджет становится общим на всех.
|
||||
if err := httpcontroller.ApplyTrustedProxyHeaders(storage); err != nil {
|
||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
||||
}
|
||||
|
||||
httpcontroller.RegisterServiceRoutes(se.Router, mounts)
|
||||
|
||||
// Раздача приложения вешается последней: она занимает корень, и всё,
|
||||
|
||||
+31
-44
@@ -60,56 +60,43 @@ object_storage_region = "ru-central1"
|
||||
# Endpoint Object Storage
|
||||
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
||||
|
||||
# Вход через внешнего провайдера OIDC (Authelia).
|
||||
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
|
||||
# API открытым наружу.
|
||||
# Кому сервис верит на входе.
|
||||
#
|
||||
# Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком
|
||||
# `Remote-User`, сходив к Authelia. Здесь остаётся один ключ — перечень адресов,
|
||||
# чьему заголовку верить. Пустой перечень роняет старт: он значит «не верить
|
||||
# никому», то есть сервис, поднявшийся никого не узнающим.
|
||||
[auth]
|
||||
# Адрес, куда сервис уводит человека на вход
|
||||
auth_url = "https://auth.example.com/api/oidc/authorization"
|
||||
|
||||
# Адрес, где код обменивается на токен
|
||||
token_url = "https://auth.example.com/api/oidc/token"
|
||||
|
||||
# Адрес, откуда берутся сведения о вошедшем
|
||||
user_info_url = "https://auth.example.com/api/oidc/userinfo"
|
||||
|
||||
# Идентификатор клиента, заведённого у провайдера
|
||||
client_id = "transcriber"
|
||||
|
||||
# Секрет клиента; приходит из выкладки, в git не коммитится
|
||||
client_secret = ""
|
||||
|
||||
# Адрес возврата; тот же, что записан клиенту у провайдера
|
||||
redirect_url = "https://transcriber.example.com/auth/callback"
|
||||
|
||||
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
|
||||
# запуска по http://localhost, где браузер такую куку не сохранит
|
||||
secure_cookie = true
|
||||
# Адреса и подсети, с которых приходит обратный прокси. Сверяется адрес самого
|
||||
# соединения, а не пересылаемый заголовок: пересылаемым распоряжается тот, кто
|
||||
# шлёт запрос.
|
||||
#
|
||||
# **Перечень задаёт адрес прокси, а не весь частный диапазон.** Всякий, кто
|
||||
# дотянулся до сервиса с адреса из этого перечня, называет себя кем угодно и
|
||||
# получает чужой архив; `172.16.0.0/12` означало бы «любой контейнер на хосте»,
|
||||
# включая чужие проекты. На сервере сюда ставят адрес сети, в которой стоит
|
||||
# Caddy, — узкий и свой.
|
||||
trusted_proxies = ["172.20.0.0/24"]
|
||||
|
||||
# Локальный вход без Authelia.
|
||||
#
|
||||
# Провайдер на машине разработчика не поднимается, а ключи боевого на ней лежать
|
||||
# не должны — вместо провайдера идёт заглушка `cmd/oidcstub`. Она отвечает на
|
||||
# `/authorize`, `/token` и `/userinfo` и не сверяет ни секрет клиента, ни
|
||||
# проверочный код PKCE: вход у неё один и заранее известный. Поднимается
|
||||
# отдельным процессом:
|
||||
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
|
||||
# приложение локально не открылось бы вовсе. На место контура встаёт подставной
|
||||
# прокси из оснастки: он слушает свой порт, ставит заголовок и переправляет
|
||||
# запрос сервису. Проверок он не делает никаких — ни пароля, ни группы, ни
|
||||
# срока, — и это его назначение, а не упущение.
|
||||
#
|
||||
# go run ./cmd/oidcstub
|
||||
# go run ./cmd/devtools proxy
|
||||
#
|
||||
# Замени секцию `[auth]` выше на эти значения — вход пойдёт настоящим путём,
|
||||
# вплоть до куки сессии и заведённой учётной записи в панели:
|
||||
# Приложение после этого открывают по адресу прокси — http://localhost:9000, —
|
||||
# а не по адресу сервиса: запрос мимо прокси приходит без заголовка и никого не
|
||||
# узнаёт.
|
||||
#
|
||||
# auth_url = "http://localhost:9000/authorize"
|
||||
# token_url = "http://localhost:9000/token"
|
||||
# user_info_url = "http://localhost:9000/userinfo"
|
||||
# client_id = "transcriber"
|
||||
# client_secret = "local"
|
||||
# redirect_url = "http://localhost:8080/auth/callback"
|
||||
# secure_cookie = false
|
||||
# Под него в перечне выше должен стоять петлевой адрес:
|
||||
#
|
||||
# Другое значение `-sub` заводит второго вошедшего: это неизменяемый признак,
|
||||
# по которому обмен ищет учётную запись. Почта переезжает вместе с ним сама —
|
||||
# заглушка выводит её из `-sub`, — и это обязательно: при общей почте второй вход
|
||||
# приходит к первой записи и получает отказ.
|
||||
# trusted_proxies = ["127.0.0.1"]
|
||||
#
|
||||
# go run ./cmd/oidcstub -sub local-2
|
||||
# Второй вошедший получается другим значением `-user`: логин у провайдера и есть
|
||||
# ключ учётной записи.
|
||||
#
|
||||
# go run ./cmd/devtools proxy -user local-2 -name "Второй"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
|
||||
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
пункт 6
|
||||
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
|
||||
|
||||
- **Дата:** 2026-08-22
|
||||
- **Источник:** openspec/changes/archive/trusted-header-login/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Сервис перестаёт вести вход сам. Кто пришёл, он узнаёт из заголовка
|
||||
`Remote-User`, поставленного обратным прокси, который сходил к Authelia;
|
||||
заголовку верят только с адреса из объявленного перечня, а адрес берётся у
|
||||
самого соединения. Учётная запись заводится первым обращением с новым логином и
|
||||
находится по нему же дальше.
|
||||
|
||||
Убраны целиком: корень `/auth` с тремя адресами, куки `transcriber_session` и
|
||||
`transcriber_login`, сверка состояния и проверочный код PKCE, обмен кода
|
||||
внутрипроцессным запросом к роутеру хранилища, слои предъявления куки и запрета
|
||||
продления, приведение настроек провайдера к конфигу, секрет клиента и срок жизни
|
||||
сессии.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата из источника, раздел Р3:
|
||||
|
||||
> Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
|
||||
> заголовку, который прокси поставил, сходив к Authelia.
|
||||
>
|
||||
> Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
|
||||
> значение, живущее семь суток, отозванный у провайдера человек работал до
|
||||
> истечения этого значения, и другого канала отзыва не было.
|
||||
|
||||
Оттуда же, Р1 — почему доверие судится адресом соединения, а не пересылаемым
|
||||
заголовком:
|
||||
|
||||
> **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
|
||||
> запрос. Барьер, который подделывается той же строкой, что и обходится, не
|
||||
> барьер вовсе.
|
||||
|
||||
Отвергнут промежуточный вариант — заголовок как вход, сессия хранилища как
|
||||
продолжение (Р3):
|
||||
|
||||
> Дешевле в работе (слой срабатывал бы раз в неделю, а не на каждом запросе), но
|
||||
> возвращает ровно то, что задача убирает: значение, переживающее отзыв. Семь
|
||||
> суток вернулись бы вместе с ним.
|
||||
|
||||
Контур к решению был готов заранее: обратный прокси уже отдавал `Remote-*` трём
|
||||
соседним сервисам того же контура, а правила для этого сервиса там не было
|
||||
вовсе — он не выложен.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Отзыв доступа действует со следующего запроса, а не через семь суток:
|
||||
Authelia судит каждое обращение.
|
||||
- `+` Секрет клиента исчез из конфига и из базы. Изъятие из инварианта «Секрет не
|
||||
покидает конфиг» снято: чтение файла базы больше не равносильно чтению
|
||||
секрета.
|
||||
- `+` Своего протокола входа у сервиса не осталось — вместе с ним исчезли пять
|
||||
накопившихся задач о его механике.
|
||||
- `+` Панель закрывается доменом, а не правилом на литерал пути; обход подменой
|
||||
знака перестаёт существовать.
|
||||
- `−` **Весь барьер держится на настройке прокси.** Прокси, добавляющий заголовок
|
||||
вместо замены, открывает сервис любому под любым именем. Половину беды сервис
|
||||
закрывает сам — запрос с двумя значениями заголовка не узнаёт никого, — вторую
|
||||
проверить отсюда нечем: правило живёт в `pet-project-server`.
|
||||
- `−` **Логин у провайдера переиспользуем**, и новый его владелец получает архив
|
||||
прежнего. Неизменяемого признака заголовок не приносит; не допускать
|
||||
переиспользования — работа провайдера. Обратная сторона: переименование
|
||||
заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни
|
||||
убрать.
|
||||
- `−` Поиск учётной записи идёт на каждом запросе к области приложения вместо
|
||||
раза в неделю. Уникальный индекс делает это одним обращением к базе; замера не
|
||||
требовалось — сервисом пользуются единицы человек.
|
||||
- `−` Половина работы лежит вне репозитория: до того как правило прокси и правило
|
||||
Authelia на домен заведут, сервис не узнает никого.
|
||||
+3
-2
@@ -35,6 +35,7 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
|
||||
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
|
||||
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
|
||||
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
|
||||
@@ -49,9 +50,9 @@
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
|
||||
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
|
||||
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
|
||||
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
|
||||
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
|
||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
|
||||
|
||||
+38
-15
@@ -15,7 +15,7 @@
|
||||
[conventions/go-linters.md](conventions/go-linters.md).
|
||||
|
||||
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
|
||||
входов**: приём за сессией, имя отправителя не доходит ни до
|
||||
входов**: приём только от узнанного, имя отправителя не доходит ни до
|
||||
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
|
||||
наблюдатель видит единственный поднятый вход. Задачи
|
||||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||
@@ -51,9 +51,10 @@
|
||||
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
|
||||
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
|
||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
|
||||
его дальше: узнавание по заголовку доверенного источника, заведение учётной
|
||||
записи первым обращением и то, какие адреса остаются открытыми. Собственный
|
||||
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
|
||||
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
|
||||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
|
||||
записи не бывает вовсе — колонка владельца пустого значения не принимает.
|
||||
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
|
||||
@@ -150,6 +151,18 @@
|
||||
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
|
||||
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
|
||||
выкладки `record-centric-model` откат образа работает, после — нет.
|
||||
- **Откат образа через шаг схемы `202608220001` обрывает вход.** Шаг закрывает
|
||||
правила коллекции пользователей наглухо, а прежний образ заводил учётную
|
||||
запись внутренним запросом обмена кода — и этот запрос закрытое правило
|
||||
отвергает. Проверено прогоном прежнего кода поверх нового каталога данных:
|
||||
вход отвечает `401`, в журнале «storage rejected the exchange with code 403».
|
||||
Порог тот же по форме, что и у `202608140002`: до выкладки
|
||||
`trusted-header-login` откат работает, после — нет, и лечится он повторной
|
||||
выкладкой вперёд. Обратного шага схемы нет и не планируется.
|
||||
|
||||
Окно этого порога сегодня пусто: сервис не выложен, а откат уже не работает с
|
||||
шага `202608140002`. Строка стоит здесь потому, что порог принято называть
|
||||
прямо, а не потому, что риск сегодня чем-то грозит.
|
||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||
@@ -199,7 +212,8 @@
|
||||
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
|
||||
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
||||
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||||
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути и уровень журнала |
|
||||
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
|
||||
| Узнавание предъявителя | `pbrepo.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод — `internal/controller/http.TrustedHeaderIdentity` |
|
||||
|
||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
|
||||
@@ -217,11 +231,20 @@
|
||||
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
|
||||
собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
|
||||
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
|
||||
работает под непривилегированным пользователем `transcriber`.
|
||||
работает под непривилегированным пользователем `transcriber` — в образе он
|
||||
назван числом, `USER 1000:1000`, а не именем: имя разрешает в идентификатор сам
|
||||
образ, и хост, которому надо понять владельца файлов в смонтированном каталоге,
|
||||
разрешить его не может. Числа те же, что при заведении пользователя.
|
||||
|
||||
Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь
|
||||
пакет: рядом в `cmd/` живёт `oidcstub`, подставной провайдер OIDC для локального
|
||||
входа, и в образе ему делать нечего.
|
||||
пакет: рядом в `cmd/` живёт `devtools`, оснастка разработчика, и в образе ей
|
||||
делать нечего.
|
||||
|
||||
Оснастка лежит **одним** пакетом с подкомандами, а не пакетом на инструмент, и
|
||||
это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки
|
||||
здесь, «Деплой» в этом файле, «Команды» в памятке, `README`, — и забытая строка
|
||||
сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||
четыре места однажды, сколько бы подкоманд в нём ни завелось.
|
||||
|
||||
Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес:
|
||||
у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому
|
||||
@@ -237,13 +260,13 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер —
|
||||
Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
|
||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
|
||||
живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
|
||||
[access](../openspec/specs/access/spec.md), решения —
|
||||
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
|
||||
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
|
||||
- **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
|
||||
обратный прокси, сходив к Authelia; учётная запись заводится первым обращением
|
||||
и находится по логину у провайдера. Задача `trusted-header-login` 2026-08-22.
|
||||
Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа
|
||||
судит провайдер на каждом запросе, а не однажды выданное значение. Норма —
|
||||
[access](../openspec/specs/access/spec.md), решение —
|
||||
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||||
зависит возвращение убранного входа.
|
||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
|
||||
+17
-14
@@ -57,16 +57,17 @@ force_shutdown_timeout = <N> # ждать остановки ворке
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||
|
||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
|
||||
говорит, какой формы значение здесь ждут. Пустым оставлен только
|
||||
`client_secret` — он и есть секрет.
|
||||
*Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
|
||||
примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
|
||||
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
|
||||
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
|
||||
входом.
|
||||
|
||||
Там же, комментарием под секцией, стоит **второй набор значений `[auth]` — под
|
||||
подставной провайдер `cmd/oidcstub`**. Они — не живая форма, и это намеренно:
|
||||
образец описывает боевую выкладку, а локальный вход — способ до неё дойти, и два
|
||||
рабочих набора в одном файле читались бы как выбор без указания, какой из них
|
||||
чей.
|
||||
Там же, комментарием под секцией, стоит **второе значение перечня — петлевой
|
||||
адрес, под подставной прокси `cmd/devtools proxy`**. Оно стоит закомментированным, и это
|
||||
намеренно: образец описывает боевую выкладку, а локальный вход — способ до неё
|
||||
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
|
||||
какое из них чьё.
|
||||
|
||||
## Поля по дискриминатору `type`
|
||||
|
||||
@@ -95,7 +96,8 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
|
||||
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
|
||||
`yandex.object_storage_access_key_id`,
|
||||
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||
`yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
|
||||
2026-08-22 вместе с собственным входом.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
владелец — пользователь процесса (`1000:1000`).
|
||||
- В `config.example.toml` секретные поля — пустые строки.
|
||||
@@ -137,10 +139,11 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет
|
||||
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
|
||||
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
|
||||
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
|
||||
`client_secret` в журнал попасть не должно.
|
||||
процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с
|
||||
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
|
||||
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
|
||||
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
|
||||
силе для прочих секций, где секреты есть.
|
||||
|
||||
## Структура в коде
|
||||
|
||||
|
||||
@@ -31,10 +31,13 @@ OpenSpec.
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
|
||||
```
|
||||
|
||||
*Расхождение:* текстовый обработчик ставят **оба** пакета `cmd/` —
|
||||
`slog.NewTextHandler(os.Stdout, …)` и в `transcriber`, и в `oidcstub`. У второго
|
||||
это выбор, а не долг: его вывод читает человек в терминале, и разбирать его
|
||||
отбором никто не станет.
|
||||
*Расхождение:* текстовый обработчик ставит `cmd/transcriber` —
|
||||
`slog.NewTextHandler(os.Stdout, …)`.
|
||||
|
||||
*Изъятие:* оснастка разработчика `cmd/devtools` печатает не через `slog`, а
|
||||
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
|
||||
терминале, в сбор он не едет, а текст подсказки по командам `slog`-ом
|
||||
выглядел бы хуже, чем есть.
|
||||
|
||||
## Сообщение
|
||||
|
||||
@@ -103,6 +106,7 @@ OpenSpec.
|
||||
| Когда добавляем | Поля |
|
||||
| --- | --- |
|
||||
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
|
||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||
| на запись об ошибке | `error` |
|
||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||
|
||||
@@ -75,8 +75,10 @@
|
||||
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
||||
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
|
||||
`404`; путь внутри корня в приложение не проваливается никогда. Корней
|
||||
сегодня четыре — `/api/` у хранилища, `/app/` у приложения, `/auth/` у входа,
|
||||
`/_/` у панели, — плюс `/health` и `/metrics` отдельными адресами. Приложение
|
||||
сегодня три — `/api/` у хранилища, `/app/` у приложения, `/_/` у панели, —
|
||||
плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят
|
||||
2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями
|
||||
вне корней. Приложение
|
||||
уехало из общего `/api/` решением владельца 2026-08-15: пространство
|
||||
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
|
||||
нашим. Перечень корней сервису не описывают, а из него **порождают**
|
||||
@@ -104,9 +106,11 @@
|
||||
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
|
||||
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
|
||||
не `Response`.
|
||||
- **Сессия живёт кукой `transcriber_session`**, и приложение её не читает: кука
|
||||
`HttpOnly`, браузер шлёт её сам, а вошедшего экран узнаёт по ответу API. Норма
|
||||
— [access](../../openspec/specs/access/spec.md).
|
||||
- **Сессии у сервиса нет вовсе**, и приложение не хранит ничего: кто пришёл,
|
||||
называет заголовок обратного прокси, а приложение узнаёт его ответом API.
|
||||
Кук сервис не ставит — это свойство сторожится проверкой. Норма —
|
||||
[access](../../openspec/specs/access/spec.md), решение —
|
||||
[ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||
|
||||
## Показ ошибок и состояний
|
||||
|
||||
|
||||
+39
-10
@@ -271,15 +271,33 @@ capability, и третий смысл развёл бы одно слово п
|
||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
|
||||
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
|
||||
файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
|
||||
файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт
|
||||
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
|
||||
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
|
||||
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
|
||||
сужает: создание записи разрешено только контексту обмена OIDC
|
||||
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
|
||||
Без этого сужения закрытие API обходится двумя запросами — завести себе
|
||||
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
|
||||
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
|
||||
- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают.
|
||||
`202608120001` выключил вход по паролю и одноразовый код; `202608220001`
|
||||
довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление,
|
||||
чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища
|
||||
означает «только владелец панели».
|
||||
|
||||
Правку и удаление умолчание библиотеки открывало владельцу записи
|
||||
(`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой
|
||||
предъявления жил под корнем приложения, и браузер до поверхности хранилища не
|
||||
дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи
|
||||
лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого
|
||||
имени. Наш код читает и заводит запись мимо правил, панель работает
|
||||
суперпользователем, своих экранов профиля сервис не заводит.
|
||||
|
||||
- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом,
|
||||
заведена шагом `202608220001`. В ней логин человека **у провайдера** — то
|
||||
значение, которым его называет обратный прокси заголовком. По нему запись
|
||||
ищется и по нему же заводится при первом обращении.
|
||||
|
||||
Почта в той же коллекции переведена в необязательную тем же шагом: провайдер
|
||||
не обязан её приносить, а ключом она не служит. Уникальность почты держится
|
||||
**частичным** индексом (`WHERE email != ''`), поэтому записи без почты
|
||||
уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с
|
||||
пустым ключом схема не примет вовсе.
|
||||
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
@@ -324,6 +342,14 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
|
||||
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
|
||||
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
||||
|
||||
**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при
|
||||
подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище
|
||||
ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и
|
||||
тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь
|
||||
одновременно открытых карточек выбирают его целиком. Требование к контуру,
|
||||
которое отсюда следует, записано в [security.md](security.md), «Периметр»:
|
||||
`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать.
|
||||
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
|
||||
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
||||
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
||||
@@ -339,9 +365,12 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||
| Срок жизни сессии | нормирует [access](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
|
||||
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||
| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки |
|
||||
|
||||
Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии,
|
||||
потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся
|
||||
вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом
|
||||
запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться.
|
||||
|
||||
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||
|
||||
+15
-13
@@ -21,7 +21,7 @@
|
||||
| --- | --- |
|
||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня не работает вовсе: домен целиком стоит за обратным прокси, и запрос программы отбивает он, не доходя до сервиса. Токен и правило прокси мимо входа приносит `api-tokens` |
|
||||
|
||||
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
|
||||
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
|
||||
@@ -61,10 +61,13 @@ Telegram.
|
||||
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
|
||||
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
|
||||
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
|
||||
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось
|
||||
2026-08-11 вместе с решением про PocketBase: в панель администратора владелец
|
||||
входит своим паролем, потому что подпустить к ней внешнего провайдера
|
||||
PocketBase не даёт.
|
||||
провайдер, свою регистрацию и свои пароли не делаем. Своя строка учётной
|
||||
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
|
||||
имя, названное провайдером, — заводит строку при первом обращении под новым
|
||||
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
|
||||
сервис не решает никогда. Одно исключение появилось 2026-08-11 вместе с
|
||||
решением про PocketBase: в панель администратора владелец входит своим
|
||||
паролем, потому что подпустить к ней внешнего провайдера PocketBase не даёт.
|
||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||
обрабатываем.
|
||||
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
||||
@@ -97,11 +100,10 @@ Telegram.
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
|
||||
токеном, получает идентификатор записи и читает её карточку
|
||||
`GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
|
||||
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только
|
||||
предъявившему сессию OIDC: анонимный запрос всеми адресами отклоняется. Своего
|
||||
входа у программы нет — его заводит `api-tokens`. Записи при этом
|
||||
разграничены: программа с чужой сессией видит только записи того, чью сессию
|
||||
предъявила.
|
||||
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только тому, кого
|
||||
назвал доверенный источник: неузнанный запрос всеми адресами отклоняется.
|
||||
Своего способа представиться у программы нет — его заводит `api-tokens`.
|
||||
Записи при этом разграничены: видны только записи того, чьим именем пришли.
|
||||
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
|
||||
получает признак остановки с причиной, и карточка записи отдаёт признак и
|
||||
причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
|
||||
@@ -119,6 +121,6 @@ Telegram.
|
||||
задача `pocketbase-storage` 2026-08-12
|
||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
|
||||
держит хранилище, файлы и панель владельца. Схема и
|
||||
раскладка — [database.md](database.md). Учётные записи она хранит и получает от
|
||||
Authelia своим провайдером OIDC, но источником их не становится: заводит и
|
||||
проверяет людей по-прежнему Authelia.
|
||||
раскладка — [database.md](database.md). Учётные записи она хранит, а заводит их
|
||||
сервис по имени, названному Authelia; источником людей она при этом не
|
||||
становится: заводит и проверяет их по-прежнему Authelia.
|
||||
|
||||
+24
-15
@@ -172,13 +172,15 @@
|
||||
Форма: `<тема>: <вопрос> (<откуда>)`.
|
||||
|
||||
- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
|
||||
в проверках. Подставных провайдера OIDC в репозитории теперь два — `cmd/oidcstub`
|
||||
и `fakeProvider` в `internal/controller/http/login_test.go`, — с теми же
|
||||
адресами и той же посылкой про `email_verified`, и они уже разошлись в мелочи
|
||||
(`token_type` «bearer» против «Bearer»). Тем же вопросом судится подставной
|
||||
распознаватель. Записанной конвенции о единственном доме подставных внешних
|
||||
собеседников у проекта нет, поэтому спрашивать надо, а не считать нарушением
|
||||
(ревью задачи про заглушку OIDC, 2026-08-15).
|
||||
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
|
||||
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
|
||||
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
|
||||
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте
|
||||
`cmd/devtools proxy`, а проверки ставят заголовок сами и подставного собеседника
|
||||
не держат вовсе. Тем же вопросом судится подставной распознаватель. Записанной
|
||||
конвенции о единственном доме подставных внешних собеседников у проекта нет,
|
||||
поэтому спрашивать надо, а не считать нарушением (ревью задачи про заглушку
|
||||
OIDC, 2026-08-15).
|
||||
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||||
@@ -294,13 +296,14 @@ API и имя не откатываются обратной правкой по
|
||||
день, и утверждения о росте остаются условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||
отдан внешней программе, и она вне нашей границы;
|
||||
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
|
||||
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
|
||||
выкладки вне репозитория, и по коду её не проверить
|
||||
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
|
||||
сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
|
||||
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
|
||||
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
|
||||
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
|
||||
- `security`: поведение браузера с куками — применение `SameSite`, приём
|
||||
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
|
||||
этого рода остаются гипотезами.
|
||||
- `security`: поведение браузера с куками. Своих кук сервис больше не ставит
|
||||
(2026-08-22), и класс сузился до кук, которые ставит панель хранилища; браузера
|
||||
в прогоне нет, и находки этого рода остаются гипотезами.
|
||||
|
||||
**Перестали проверять сознательно:**
|
||||
|
||||
@@ -321,8 +324,14 @@ API и имя не откатываются обратной правкой по
|
||||
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
|
||||
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
|
||||
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
|
||||
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
|
||||
тоже недоступен: сессию в прогоне выдать нечем.
|
||||
остановку; нельзя — расшифровку и заливку.
|
||||
|
||||
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
|
||||
сессию в прогоне выдать было нечем; теперь заголовок ставит `cmd/devtools
|
||||
proxy`, и живьём проверяются узнавание, заведение учётной записи первым
|
||||
обращением, отказ с недоверенного адреса и отказ старта на пустом перечне.
|
||||
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
|
||||
контуре (см. «Не проверит ни один проход»).
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
|
||||
+106
-53
@@ -3,14 +3,21 @@
|
||||
## Периметр
|
||||
|
||||
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
|
||||
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Без
|
||||
входа открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
|
||||
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
|
||||
`trusted-header-login`, называет она его **заголовком, который ставит обратный
|
||||
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
|
||||
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
|
||||
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
|
||||
**каждый** запрос, и отзыв доступа действует со следующего.
|
||||
|
||||
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
|
||||
принадлежащий ни одному корню сервиса. Иначе не вошедший не дошёл бы до входа
|
||||
вовсе: закрытая сессией разметка отдала бы ему отказ вместо экрана. Данных
|
||||
открытость не касается — всякий адрес под корнем приложения сессии по-прежнему
|
||||
требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
|
||||
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
|
||||
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
|
||||
открытость не касается — всякий адрес под корнем приложения узнанного
|
||||
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||
|
||||
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
|
||||
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
|
||||
@@ -58,13 +65,49 @@ Telegram — связи чата с учётной записью сервис
|
||||
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
|
||||
прокси живёт в `pet-project-server`, вне этого репозитория.
|
||||
|
||||
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
|
||||
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
|
||||
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
|
||||
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
|
||||
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
|
||||
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
|
||||
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
|
||||
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||||
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||||
секрет в настройки коллекции пользователей, и чтение файла базы становилось
|
||||
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
|
||||
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
|
||||
|
||||
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
|
||||
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
|
||||
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
|
||||
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
|
||||
и аноним войдёт под любым именем. Половину этой беды сервис закрывает сам:
|
||||
запрос с двумя значениями `Remote-User` не узнаёт никого. Вторую половину
|
||||
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
|
||||
репозитория `pet-project-server`, и **требование к нему такое — заголовки
|
||||
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||||
человек.
|
||||
|
||||
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.**
|
||||
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса
|
||||
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а
|
||||
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси,
|
||||
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика
|
||||
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером
|
||||
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого
|
||||
соединения.
|
||||
|
||||
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||||
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||||
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
|
||||
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
|
||||
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
|
||||
именно поэтому.
|
||||
|
||||
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
|
||||
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
|
||||
после ухода прежнего владельца, и тогда новый человек при первом же обращении
|
||||
попадает в **существующую** запись и получает весь её архив — самое
|
||||
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
|
||||
может: неизменяемого признака заголовок не приносит. Не допускать
|
||||
переиспользования — работа провайдера, и это принятая цена, записанная в
|
||||
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
|
||||
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
|
||||
нечем ни слить, ни убрать.
|
||||
|
||||
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||||
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
|
||||
@@ -79,10 +122,11 @@ Telegram — связи чата с учётной записью сервис
|
||||
|
||||
| Вход | Канал | Кто может слать |
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой записи |
|
||||
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой вошедший; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой вошедший; значение вне закрытого перечня даёт `400` |
|
||||
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения |
|
||||
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
|
||||
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
|
||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||
|
||||
@@ -92,7 +136,6 @@ Telegram — связи чата с учётной записью сервис
|
||||
| Вход | Канал | Кто может слать | Чья задача |
|
||||
| --- | --- | --- | --- |
|
||||
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
|
||||
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
|
||||
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
|
||||
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
|
||||
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
|
||||
@@ -179,39 +222,48 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
|
||||
([database.md](database.md), «Настройки с числовым значением»).
|
||||
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
||||
бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
|
||||
доходит до сервиса, — не значил бы ничего.
|
||||
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
||||
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
||||
предъявить ту же сессию.
|
||||
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
|
||||
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
|
||||
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
|
||||
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
|
||||
здесь «сессия → токен файла → ссылка».
|
||||
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
|
||||
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
|
||||
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||||
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||||
отзыв доступа у Authelia действует со следующего обращения.
|
||||
Предъявленный собственный токен хранилища побеждает заголовок: им работает
|
||||
владелец панели, и подмена его учётной записью пользователя отобрала бы у него
|
||||
панель. Протухший и негодный токен предъявленными не считаются.
|
||||
**Область узнавания сужена** до корня приложения и адреса выдачи файлового
|
||||
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный
|
||||
переписал бы себе ключ учётной записи на чужое имя.
|
||||
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||||
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||||
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом
|
||||
схемы `202608220001`.
|
||||
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла
|
||||
помечено защищённым, правило просмотра коллекции пускает только владельца
|
||||
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного
|
||||
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это
|
||||
единственное значение, переживающее запрос**, и на его срок отзыв доступа до
|
||||
файловой ссылки не доходит.
|
||||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||
2026-08-12.
|
||||
- **Заведение учётной записи** — только входом у провайдера. Собственное
|
||||
создание записи, вход по паролю, одноразовый код и восстановление доступа
|
||||
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
|
||||
без этого закрытия вход обходился бы двумя запросами.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
|
||||
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
|
||||
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по
|
||||
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление
|
||||
доступа и продление — ни один не даёт доступа и не меняет учётной записи:
|
||||
хранилище заводит коллекцию пользователей открытой, и без этого закрытия
|
||||
узнавание обходилось бы двумя запросами.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||||
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||||
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||
эти адреса не несут.
|
||||
- **Приложение** — его разметка и ресурсы открыты без сессии, и ограничителя
|
||||
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
|
||||
частоты на них нет: правило заведено под корень приложения, а раздача стоит
|
||||
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
|
||||
для всех и собраны до всякого запроса. По ответу нельзя узнать, вошёл ли
|
||||
кто-то, — вошедшему и не вошедшему отдаётся одно и то же.
|
||||
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
|
||||
кто-то, — узнанному и неузнанному отдаётся одно и то же.
|
||||
|
||||
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||||
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||||
@@ -222,9 +274,9 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
| Механизм | Что даёт | Чья задача |
|
||||
| --- | --- | --- |
|
||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
||||
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
|
||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
|
||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
|
||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||
|
||||
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
||||
@@ -252,8 +304,8 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
наравне с записью — норму держит спека `storage`.
|
||||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
|
||||
приложение от чужого имени.
|
||||
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
|
||||
из базы вместе с собственным входом.
|
||||
|
||||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||||
@@ -335,13 +387,14 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
|
||||
Ansible — не наша граница.
|
||||
- **Машина разработчика и то, что он на ней поднимает.** С 2026-08-15 в
|
||||
репозитории лежит `cmd/oidcstub` — подставной провайдер OIDC, который выдаёт
|
||||
сессию всякому спросившему и не сверяет ни секрета клиента, ни проверочного
|
||||
кода PKCE. Двух вещей это не отменяет, и обе проверяемы: в образ он не едет
|
||||
(ступень собирает `./cmd/transcriber` поимённо), а слушает петлевой адрес.
|
||||
Периметра выкладки заглушка поэтому не касается; кто поднял её у себя в чужой
|
||||
сети, отвечает за это сам.
|
||||
- **Машина разработчика и то, что он на ней поднимает.** В репозитории лежит
|
||||
`cmd/devtools` — оснастка разработчика; её подкоманда `proxy` встаёт на место
|
||||
контура: ставит заголовок `Remote-User` и переправляет запрос сервису, не
|
||||
проверяя ничего. С 2026-08-15 по 2026-08-22 ту же роль играл `cmd/oidcstub`,
|
||||
подставной провайдер OIDC. Двух вещей это не отменяет, и обе проверяемы: в
|
||||
образ оснастка не едет (ступень собирает `./cmd/transcriber` поимённо), а
|
||||
слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял
|
||||
её у себя в чужой сети, отвечает за это сам.
|
||||
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
||||
доверяем полностью.
|
||||
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0
|
||||
github.com/pocketbase/pocketbase v0.39.10
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
@@ -54,7 +55,6 @@ require (
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// MaxProviderLoginLength — предел длины логина у провайдера.
|
||||
//
|
||||
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
|
||||
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
|
||||
// же, что у имени в умолчании библиотеки: длиннее имени логин не бывает, а два
|
||||
// разных предела на соседних колонках одной записи разошлись бы молча.
|
||||
const MaxProviderLoginLength = 255
|
||||
|
||||
// MaxDisplayNameLength — предел длины имени, пригодного к показу. Число то же и
|
||||
// по той же причине: столько держит колонка имени в умолчании библиотеки.
|
||||
const MaxDisplayNameLength = 255
|
||||
|
||||
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
|
||||
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а
|
||||
// негодный ввод, и звать по нему учётную запись не надо.
|
||||
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
|
||||
|
||||
// Identity — то, чем доверенный источник называет пришедшего.
|
||||
//
|
||||
// Логин — ключ, остальное берётся только при заведении записи.
|
||||
type Identity struct {
|
||||
Login string
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
// EnsureUser находит учётную запись по логину у провайдера, а не найдя —
|
||||
// заводит её.
|
||||
//
|
||||
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
|
||||
// представиться — личные токены — придёт следующей задачей и возьмёт этот же
|
||||
// метод; правило, уложенное куском в слой транспорта, пришлось бы тогда либо
|
||||
// дублировать вторым куском, либо вытаскивать задним числом.
|
||||
//
|
||||
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
|
||||
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
|
||||
// его работы.
|
||||
//
|
||||
// Сравнение точное, знак в знак: приведение регистра завело бы правило, которого
|
||||
// у провайдера нет, — считает ли он `admin` и `Admin` одним человеком, сервису
|
||||
// неизвестно, а угаданное правило склеило бы двух разных людей.
|
||||
func EnsureUser(app core.App, identity Identity) (record *core.Record, created bool, err error) {
|
||||
login, ok := AcceptProviderLogin(identity.Login)
|
||||
if !ok {
|
||||
return nil, false, ErrLoginNotAcceptable
|
||||
}
|
||||
|
||||
record, err = findUserByLogin(app, login)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if record != nil {
|
||||
return record, false, nil
|
||||
}
|
||||
|
||||
users, err := findCollection(app, migrations.UsersCollection)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
record = core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
// Имя и почта принимаются так же, как логин, а не кладутся как есть.
|
||||
// Значения приходят заголовками, то есть задаются тем, кто шлёт запрос;
|
||||
// имя длиннее предела колонки отвергается проверкой записи, и человек с
|
||||
// таким именем у провайдера не завёлся бы **никогда** — каждый его запрос
|
||||
// отвечал бы отказом сервиса. Негодное значение необязательного поля не
|
||||
// вправе отменять заведение записи.
|
||||
record.Set("name", acceptDisplayName(identity.Name))
|
||||
if email, ok := acceptEmail(identity.Email); ok {
|
||||
record.SetEmail(email)
|
||||
}
|
||||
// Пароль записи обязателен при любом значении признака — это проверка самой
|
||||
// библиотеки, а не колонки. Ставится случайный: употребить его нельзя,
|
||||
// потому что вход по паролю у коллекции выключен шагом схемы.
|
||||
record.SetRandomPassword()
|
||||
|
||||
if err := app.Save(record); err != nil {
|
||||
record, err = retryAfterConflict(app, login, record, err)
|
||||
return record, record != nil, err
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// retryAfterConflict разбирает отказ сохранения. Два отказа уникальности здесь
|
||||
// разные, и исход у них разный.
|
||||
//
|
||||
// По **ключевой** колонке — это гонка двух первых обращений одним логином:
|
||||
// запись успел завести соседний запрос, и надо просто взять его. Отказ, который
|
||||
// после повторного поиска никуда не делся, — уже не гонка, и его отдают наверх.
|
||||
//
|
||||
// По **любой другой** — почта, пришедшая от провайдера, занята другой учётной
|
||||
// записью: общий почтовый ящик, семья, группа. Запись заводится без почты; она
|
||||
// необязательна, а ключом не служит. Без этого разреза второй человек с общим
|
||||
// адресом не завёлся бы никогда — повторный поиск по логину снова ничего не
|
||||
// нашёл бы, и исход выродился бы либо в цикл, либо в вечный отказ без внятной
|
||||
// причины.
|
||||
func retryAfterConflict(app core.App, login string, record *core.Record, saveErr error) (*core.Record, error) {
|
||||
if isUniqueViolation(saveErr, migrations.ProviderLoginField) {
|
||||
existing, err := findUserByLogin(app, login)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
|
||||
}
|
||||
|
||||
if !isUniqueViolation(saveErr, core.FieldNameEmail) {
|
||||
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
|
||||
}
|
||||
|
||||
record.SetEmail("")
|
||||
if err := app.Save(record); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user account without email: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// findUserByLogin ищет учётную запись по ключу. Значение уходит хранилищу
|
||||
// **параметром** запроса, а не подстановкой в текст фильтра: строка приходит
|
||||
// снаружи, и подставленная в текст она правила бы сам запрос, а не только его
|
||||
// аргумент.
|
||||
func findUserByLogin(app core.App, login string) (*core.Record, error) {
|
||||
records, err := app.FindRecordsByFilter(
|
||||
migrations.UsersCollection,
|
||||
migrations.ProviderLoginField+" = {:login}",
|
||||
"", 1, 0,
|
||||
map[string]any{"login": login},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to look up user account: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return records[0], nil
|
||||
}
|
||||
|
||||
// uniqueViolationCode — каким кодом библиотека называет отказ уникальности.
|
||||
//
|
||||
// Разбор идёт по **коду**, а не по имени текста и не по тексту драйвера: текст
|
||||
// у драйвера свой на каждую версию, а имя колонки не говорит о причине.
|
||||
const uniqueViolationCode = "validation_not_unique"
|
||||
|
||||
// isUniqueViolation говорит, отказала ли по названной колонке проверка
|
||||
// **уникальности** — а не какая-нибудь другая.
|
||||
//
|
||||
// Разница не педантизм. Под ключом `email` библиотека складывает и отказ
|
||||
// уникальности, и отказ формы адреса; проверка «есть ли ключ в карте» считала
|
||||
// бы опечатку прокси занятым семейным ящиком и молча заводила бы запись без
|
||||
// почты. На ключевой колонке та же неточность когда-нибудь выстрелит громче:
|
||||
// любой отказ проверки логина читался бы как гонка двух первых обращений.
|
||||
func isUniqueViolation(err error, field string) bool {
|
||||
var errs validation.Errors
|
||||
if !errors.As(err, &errs) {
|
||||
return false
|
||||
}
|
||||
|
||||
fieldErr, ok := errs[field]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var object validation.ErrorObject
|
||||
if !errors.As(fieldErr, &object) {
|
||||
return false
|
||||
}
|
||||
|
||||
return object.Code() == uniqueViolationCode
|
||||
}
|
||||
|
||||
// acceptDisplayName приводит имя к годному для колонки значению.
|
||||
//
|
||||
// Обрезается по пределу колонки и чистится от управляющих знаков — тем же
|
||||
// приёмом, каким приём записи чистит имя файла отправителя. Пустое значение
|
||||
// законно: имени у человека может не быть вовсе.
|
||||
func acceptDisplayName(value string) string {
|
||||
name := strings.TrimSpace(stripControls(value))
|
||||
|
||||
runes := []rune(name)
|
||||
if len(runes) > MaxDisplayNameLength {
|
||||
return string(runes[:MaxDisplayNameLength])
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// acceptEmail отдаёт адрес почты, если он вообще похож на адрес.
|
||||
//
|
||||
// Негодный отбрасывается **здесь**, а не отказом сохранения: иначе опечатка в
|
||||
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью
|
||||
// «почта занята», и владелец искал бы общий ящик там, где сломан контур.
|
||||
func acceptEmail(value string) (string, bool) {
|
||||
email := strings.TrimSpace(value)
|
||||
if email == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if err := is.EmailFormat.Validate(email); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return email, true
|
||||
}
|
||||
|
||||
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
|
||||
// им делать нечего.
|
||||
func stripControls(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
|
||||
// отвергает его.
|
||||
//
|
||||
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
|
||||
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
|
||||
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
|
||||
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
|
||||
//
|
||||
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
|
||||
// называет одного человека, а две записи о нём разошлись бы молча.
|
||||
func AcceptProviderLogin(value string) (string, bool) {
|
||||
login := strings.TrimSpace(value)
|
||||
|
||||
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и
|
||||
// два предела в разных единицах разошлись бы вдвое на любой кириллице.
|
||||
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, r := range login {
|
||||
if unicode.IsControl(r) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
return login, true
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// Проверки узнавания: как учётная запись находится и как заводится.
|
||||
|
||||
// Заведение идемпотентно: второе обращение попадает в ту же запись и не
|
||||
// переписывает её.
|
||||
//
|
||||
// Не переписывает — половина требования, и она отдельная: перепись на каждом
|
||||
// запросе означала бы запись в базу на каждый запрос, а правка имени у
|
||||
// провайдера меняла бы карточку человека молча, посреди его работы.
|
||||
func TestEnsureUserIsIdempotent(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
first, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Алиса", Email: "alice@example.test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Другое имя", Email: "other@example.test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first.Id, second.Id, "второе обращение завело вторую запись")
|
||||
assert.Equal(t, "Алиса", second.GetString("name"), "имя переписано вторым обращением")
|
||||
assert.Equal(t, "alice@example.test", second.Email(), "почта переписана вторым обращением")
|
||||
|
||||
assert.Equal(t, 1, countUsers(t, app))
|
||||
}
|
||||
|
||||
// Одновременные первые обращения одним логином дают одну учётную запись.
|
||||
//
|
||||
// Проверка стоит потому, что норма без неё держалась бы на одном уникальном
|
||||
// индексе: забытый в шаге схемы, он дал бы зелёную приёмку и две учётные записи
|
||||
// на одного человека — а архив разъехался бы между ними молча и склеить его было
|
||||
// бы нечем.
|
||||
func TestEnsureUserSurvivesConcurrentFirstRequests(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
const racers = 8
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ids := make([]string, racers)
|
||||
errs := make([]error, racers)
|
||||
|
||||
start := make(chan struct{})
|
||||
for i := range racers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: "racer", Name: "Гонщик"})
|
||||
errs[i] = err
|
||||
if record != nil {
|
||||
ids[i] = record.Id
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
require.NoError(t, err, "обращение %d отказало", i)
|
||||
}
|
||||
for i, id := range ids {
|
||||
assert.Equal(t, ids[0], id, "обращение %d попало в другую учётную запись", i)
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, countUsers(t, app), "гонка завела больше одной учётной записи")
|
||||
}
|
||||
|
||||
// Занятая почта не мешает завести запись: она достаётся первому, а второй
|
||||
// заводится без неё.
|
||||
//
|
||||
// Общий почтовый ящик — обычное дело в семье, а Authelia вправе отдать один
|
||||
// адрес группе. Без разреза двух отказов уникальности второй человек не завёлся
|
||||
// бы никогда: повторный поиск по логину снова ничего не находит.
|
||||
func TestEnsureUserWithTakenEmail(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
first, _, err := EnsureUser(app, Identity{Login: "one", Email: "family@example.test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, _, err := EnsureUser(app, Identity{Login: "two", Email: "family@example.test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, first.Id, second.Id)
|
||||
assert.Equal(t, "family@example.test", first.Email(), "почта досталась первому")
|
||||
assert.Empty(t, second.Email(), "второму почта не досталась, но запись завелась")
|
||||
assert.Equal(t, 2, countUsers(t, app))
|
||||
}
|
||||
|
||||
// Вырожденный логин никого не узнаёт и ничего не заводит.
|
||||
func TestEnsureUserRejectsDegenerateLogin(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
values := map[string]string{
|
||||
"пустой": "",
|
||||
"одни пробелы": " \t ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", MaxProviderLoginLength+1),
|
||||
}
|
||||
|
||||
for name, value := range values {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
record, _, err := EnsureUser(app, Identity{Login: value})
|
||||
|
||||
assert.Nil(t, record)
|
||||
require.ErrorIs(t, err, ErrLoginNotAcceptable)
|
||||
assert.Equal(t, 0, countUsers(t, app))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
|
||||
// называет одного человека, и две записи о нём разошлись бы молча.
|
||||
func TestEnsureUserTrimsSurroundingSpaces(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
first, _, err := EnsureUser(app, Identity{Login: "alice"})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, _, err := EnsureUser(app, Identity{Login: " alice "})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first.Id, second.Id)
|
||||
assert.Equal(t, 1, countUsers(t, app))
|
||||
}
|
||||
|
||||
// Сравнение точное: приведение регистра завело бы правило, которого у
|
||||
// провайдера нет, — и склеило бы двух разных людей.
|
||||
func TestEnsureUserComparesExactly(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
lower, _, err := EnsureUser(app, Identity{Login: "alice"})
|
||||
require.NoError(t, err)
|
||||
|
||||
upper, _, err := EnsureUser(app, Identity{Login: "Alice"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, lower.Id, upper.Id)
|
||||
assert.Equal(t, 2, countUsers(t, app))
|
||||
}
|
||||
|
||||
// Значение, похожее на условие отбора, ищется как значение, а не как часть
|
||||
// запроса: оно уходит хранилищу параметром.
|
||||
func TestEnsureUserDoesNotLetLoginChangeTheQuery(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
victim, _, err := EnsureUser(app, Identity{Login: "victim"})
|
||||
require.NoError(t, err)
|
||||
|
||||
attacker, _, err := EnsureUser(app, Identity{Login: `x" || provider_login = "victim`})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, victim.Id, attacker.Id,
|
||||
"значение изменило сам запрос и вернуло чужую учётную запись")
|
||||
assert.Equal(t, 2, countUsers(t, app))
|
||||
}
|
||||
|
||||
func countUsers(t *testing.T, app core.App) int {
|
||||
t.Helper()
|
||||
|
||||
records, err := app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// Признак заведения отличает первое обращение от всех следующих.
|
||||
//
|
||||
// По нему слой узнавания пишет строку журнала, и без него владелец не отличит
|
||||
// «никто не заходил» от «завелось двадцать»: убрать заведённую запись потом
|
||||
// нечем.
|
||||
func TestEnsureUserReportsWhetherItCreated(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
_, created, err := EnsureUser(app, Identity{Login: "alice"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, created, "первое обращение не назвалось заведением")
|
||||
|
||||
_, created, err = EnsureUser(app, Identity{Login: "alice"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, created, "второе обращение назвалось заведением")
|
||||
}
|
||||
|
||||
// Негодное имя не отменяет заведения: оно обрезается по пределу колонки.
|
||||
//
|
||||
// Прежде имя уходило в колонку как есть, и человек с длинным именем у
|
||||
// провайдера получал отказ сервиса на **каждом** запросе — учётная запись не
|
||||
// заводилась никогда, а починить у себя он ничего не мог.
|
||||
func TestEnsureUserAcceptsDegenerateName(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
long := strings.Repeat("я", MaxDisplayNameLength+50)
|
||||
|
||||
record, created, err := EnsureUser(app, Identity{Login: "bob", Name: long})
|
||||
require.NoError(t, err, "негодное имя отменило заведение записи")
|
||||
require.True(t, created)
|
||||
|
||||
name := record.GetString("name")
|
||||
assert.Equal(t, MaxDisplayNameLength, utf8.RuneCountInString(name), "имя не обрезано по пределу")
|
||||
assert.NotEmpty(t, name)
|
||||
}
|
||||
|
||||
// Управляющие знаки из имени убираются: значение приезжает заголовком.
|
||||
func TestEnsureUserStripsControlsFromName(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: "carol", Name: "Ка\x00ро\nл"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Карол", record.GetString("name"))
|
||||
}
|
||||
|
||||
// Негодная почта отбрасывается **явно**, а не через ветвь «почта занята».
|
||||
//
|
||||
// Иначе опечатка в контуре неотличима от общего семейного ящика, и владелец
|
||||
// ищет второго человека там, где сломан прокси.
|
||||
func TestEnsureUserDropsMalformedEmail(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
record, created, err := EnsureUser(app, Identity{Login: "dave", Email: "не-адрес"})
|
||||
require.NoError(t, err, "негодная почта отменила заведение записи")
|
||||
require.True(t, created)
|
||||
|
||||
assert.Empty(t, record.Email())
|
||||
}
|
||||
|
||||
// Предел логина считается в знаках, а не в байтах: колонка считает знаки.
|
||||
//
|
||||
// Прежде кириллический логин длиннее половины предела отвергался навсегда,
|
||||
// хотя колонка приняла бы его.
|
||||
func TestEnsureUserCountsLoginInRunes(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
login := strings.Repeat("я", MaxProviderLoginLength)
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: login})
|
||||
require.NoError(t, err, "логин ровно на пределе отвергнут: предел считается в байтах")
|
||||
assert.Equal(t, login, record.GetString(migrations.ProviderLoginField))
|
||||
|
||||
_, _, err = EnsureUser(app, Identity{Login: strings.Repeat("я", MaxProviderLoginLength+1)})
|
||||
assert.ErrorIs(t, err, ErrLoginNotAcceptable, "логин сверх предела принят")
|
||||
}
|
||||
|
||||
// Ключ учётной записи не меняется после заведения — ни правкой в панели, ни
|
||||
// прямым сохранением.
|
||||
//
|
||||
// Правила доступа коллекции закрывают только путь снаружи; панель работает
|
||||
// суперпользователем. Переписанный ключ отдал бы весь архив следующему, кто
|
||||
// придёт с этим именем, и вернуть его было бы нечем.
|
||||
func TestProviderLoginIsImmutable(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: "victim-owner"})
|
||||
require.NoError(t, err)
|
||||
|
||||
record.Set(migrations.ProviderLoginField, "someone-else")
|
||||
err = app.Save(record)
|
||||
require.Error(t, err, "ключ учётной записи переписан прямым сохранением")
|
||||
|
||||
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "victim-owner", stored.GetString(migrations.ProviderLoginField))
|
||||
}
|
||||
|
||||
// Правка прочих полей учётной записи при этом проходит: хук сторожит один ключ,
|
||||
// а не запирает коллекцию целиком.
|
||||
func TestUserRecordStaysEditableExceptTheKey(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: "editable", Name: "Прежнее"})
|
||||
require.NoError(t, err)
|
||||
|
||||
record.Set("name", "Новое")
|
||||
require.NoError(t, app.Save(record), "правка имени в панели отвергнута")
|
||||
|
||||
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Новое", stored.GetString("name"))
|
||||
}
|
||||
|
||||
// Две учётные записи без ключа уживаются: индекс частичный, как и соседний
|
||||
// индекс почты.
|
||||
//
|
||||
// Сплошной индекс ронял бы накатку шага на всякой базе, где записей больше
|
||||
// одной, — то есть у разработчика, ходившего прежним рецептом входа.
|
||||
func TestEmptyProviderLoginDoesNotCollide(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range []string{"Первый", "Второй"} {
|
||||
record := core.NewRecord(users)
|
||||
record.Set("name", name)
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record), "вторая запись без ключа отвергнута индексом")
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, countUsers(t, app))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// ProviderLoginField — колонка, в которой лежит ключ учётной записи: логин
|
||||
// человека у провайдера, тот самый, которым его называет обратный прокси.
|
||||
//
|
||||
// Имя говорит о происхождении значения, а не о заголовке, которым оно приехало:
|
||||
// заголовок — способ доставки и может смениться, а логин у провайдера — то, чем
|
||||
// значение является. Колонка уезжает шагом схемы и потому не переименовывается.
|
||||
const ProviderLoginField = "provider_login"
|
||||
|
||||
// providerLoginIndex — имя уникального индекса по ключу учётной записи.
|
||||
const providerLoginIndex = "idx_users_provider_login"
|
||||
|
||||
// up202608220001 переводит узнавание пришедшего с протокола OIDC на логин,
|
||||
// который называет доверенный источник.
|
||||
//
|
||||
// Три части, и каждая закрывает своё.
|
||||
//
|
||||
// Первая — ключ учётной записи. Прежде идентичность человека лежала в системной
|
||||
// таблице внешних учётных записей библиотеки: её вела механика обмена кода, и
|
||||
// правил её только владелец панели. Механика уходит, и ключу нужен свой дом —
|
||||
// колонка с уникальным индексом. Почта ключом не годится: провайдер не обязан
|
||||
// её приносить, человек её меняет, а первое обращение с чужим адресом досталось
|
||||
// бы чужой записи.
|
||||
//
|
||||
// Вторая — необязательная почта. Умолчание библиотеки требует непустого адреса
|
||||
// у всякой учётной записи; заголовка с почтой может не быть вовсе, а
|
||||
// уникальность почты держится **частичным** индексом (`WHERE email != ”`),
|
||||
// поэтому записи без почты уживаются друг с другом. Пароль остаётся
|
||||
// обязательным при любом значении признака — ему ставится случайный, употребить
|
||||
// его нельзя: вход по паролю у коллекции выключен прежним шагом.
|
||||
//
|
||||
// Третья — поверхность коллекции пользователей. Умолчание библиотеки открывает
|
||||
// владельцу записи чтение, правку и удаление собственной строки, и до сих пор
|
||||
// это ничему не мешало ровно потому, что до поверхности хранилища браузер с
|
||||
// кукой не дотягивался: слой предъявления жил под корнем приложения. С
|
||||
// узнаванием по заголовку такая защита перестаёт быть защитой, а ключ учётной
|
||||
// записи лежит теперь обычной колонкой — то есть правка своей записи и есть
|
||||
// захват чужого имени: поставил себе ключом чужой логин, и первое обращение
|
||||
// настоящего его владельца попало бы в твою запись вместе со всем архивом.
|
||||
// Правила снимаются в пустое, что у хранилища означает «только владелец
|
||||
// панели»; наш код читает и заводит запись мимо правил, панель работает
|
||||
// суперпользователем, своих экранов профиля сервис не заводит.
|
||||
func up202608220001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
users.Fields.Add(&core.TextField{
|
||||
Name: ProviderLoginField,
|
||||
// Предел тот же, что у имени в умолчании библиотеки: логин длиннее
|
||||
// имени не бывает, а колонка без предела принимала бы килобайты,
|
||||
// пришедшие заголовком.
|
||||
Max: 255,
|
||||
})
|
||||
// Индекс **частичный** — ровно как соседний индекс почты у той же коллекции.
|
||||
// Сплошной запретил бы вторую запись с пустым ключом, а такая заводится
|
||||
// рукой в панели и остаётся у всякой базы, пережившей прежний вход: подъём
|
||||
// на ней ронял бы накатку шага отказом уникальности, и сервис не стартовал
|
||||
// бы вовсе. Пустым ключом при этом не узнаётся никто — это держит приём
|
||||
// значения, а не индекс.
|
||||
users.AddIndex(providerLoginIndex, true, ProviderLoginField, ProviderLoginField+" != ''")
|
||||
|
||||
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
|
||||
if !ok {
|
||||
return errors.New("users collection has no email field")
|
||||
}
|
||||
email.Required = false
|
||||
|
||||
// Механика OIDC снимается целиком: настройки провайдера больше не приводятся
|
||||
// к конфигу при подъёме, и обменивать код не на что.
|
||||
users.OAuth2.Enabled = false
|
||||
users.OAuth2.Providers = nil
|
||||
|
||||
// Наглухо все пять: заведение записи идёт нашим кодом, мимо правил.
|
||||
users.ListRule = nil
|
||||
users.ViewRule = nil
|
||||
users.CreateRule = nil
|
||||
users.UpdateRule = nil
|
||||
users.DeleteRule = nil
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to switch users collection to provider login: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// down202608220001 убирает ключ учётной записи и возвращает обязательность
|
||||
// почты.
|
||||
//
|
||||
// Правила доступа сюда не возвращаются намеренно — ни открытое создание записи,
|
||||
// которое закрывал прежний шаг, ни открытая правка, которую закрывает этот.
|
||||
// Откат, восстанавливающий их, оставил бы сервис хуже, чем он был: правка своей
|
||||
// записи открыта только тому, кто узнан, а узнают теперь по колонке, которую эта
|
||||
// правка и переписывает.
|
||||
func down202608220001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
users.RemoveIndex(providerLoginIndex)
|
||||
users.Fields.RemoveByName(ProviderLoginField)
|
||||
|
||||
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
|
||||
if !ok {
|
||||
return errors.New("users collection has no email field")
|
||||
}
|
||||
email.Required = true
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to restore users collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func init() {
|
||||
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
|
||||
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
|
||||
pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go")
|
||||
pbmigrations.Register(up202608220001, down202608220001, "202608220001_trusted_header_login.go")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
@@ -31,6 +33,8 @@ import (
|
||||
// бы тем же сохранением, а число отказов остановленной записи — которое
|
||||
// остановка хранит намеренно — приходило бы владельцу нулём.
|
||||
func BindPanelRules(app core.App) {
|
||||
bindProviderLoginIsImmutable(app)
|
||||
|
||||
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
original := e.Record.Original()
|
||||
if original == nil {
|
||||
@@ -84,3 +88,44 @@ func BindPanelRules(app core.App) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// bindProviderLoginIsImmutable запрещает менять ключ учётной записи после
|
||||
// заведения.
|
||||
//
|
||||
// Ключ — логин человека у провайдера, и по нему сервис узнаёт пришедшего.
|
||||
// Переписанный, он отдаёт весь архив прежнего владельца следующему, кто придёт
|
||||
// с этим именем: владелец записи назначается один раз и не меняется, так что
|
||||
// вернуть архив будет нечем. Молча — журнала событий у коллекции пользователей
|
||||
// нет.
|
||||
//
|
||||
// Правила доступа коллекции закрывают этот путь **снаружи**, но не изнутри:
|
||||
// панель работает суперпользователем и правила обходит по построению. Отсюда
|
||||
// хук, и он вешается на **модельное** событие, а не на правку запросом — иначе
|
||||
// панель осталась бы незакрытой, а закрывать её и есть весь смысл.
|
||||
//
|
||||
// Заведение проходит: событие правки на нём не срабатывает вовсе.
|
||||
//
|
||||
// Прежнее значение читается **из базы**, а не из снимка правящейся записи.
|
||||
// Снимок у записи, только что заведённой в этом же процессе, пуст — он не
|
||||
// обновляется сохранением, — и сторож, опирающийся на него, пропускал бы правку
|
||||
// в зависимости от того, откуда вызывающий взял запись. Панель её загружает, и
|
||||
// на ней сторож сработал бы; молчаливая же зависимость от способа получения —
|
||||
// ровно тот класс, из-за которого правило и заводится.
|
||||
func bindProviderLoginIsImmutable(app core.App) {
|
||||
app.OnRecordUpdate(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
||||
stored, err := e.App.FindRecordById(migrations.UsersCollection, e.Record.Id)
|
||||
if err != nil {
|
||||
// Записи в базе нет — правки тоже нет: сохранение отвергнется само.
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
was := stored.GetString(migrations.ProviderLoginField)
|
||||
now := e.Record.GetString(migrations.ProviderLoginField)
|
||||
|
||||
if was != "" && was != now {
|
||||
return errors.New("provider login is assigned once and never changes")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
|
||||
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
|
||||
const ProviderName = "oidc"
|
||||
|
||||
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
|
||||
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
|
||||
// применяется, потому что оно никем не выбрано.
|
||||
//
|
||||
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
|
||||
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
|
||||
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
|
||||
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
|
||||
// хранилище продолжило выдавать прежний.
|
||||
const SessionDuration = 7 * 24 * 60 * 60
|
||||
|
||||
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
|
||||
// коллекции.
|
||||
type ProviderSettings struct {
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
UserInfoURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
|
||||
// к значениям конфига.
|
||||
//
|
||||
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
|
||||
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
|
||||
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
|
||||
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
|
||||
// только руками в панели.
|
||||
//
|
||||
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
|
||||
// имя коллекции, а не значения.
|
||||
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
|
||||
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
|
||||
// сроком жизни куки.
|
||||
users.AuthToken.Duration = SessionDuration
|
||||
|
||||
users.OAuth2.Enabled = true
|
||||
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: ProviderName,
|
||||
ClientId: settings.ClientID,
|
||||
ClientSecret: settings.ClientSecret,
|
||||
AuthURL: settings.AuthURL,
|
||||
TokenURL: settings.TokenURL,
|
||||
UserInfoURL: settings.UserInfoURL,
|
||||
DisplayName: "Authelia",
|
||||
}}
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -74,6 +74,35 @@ func TestRecordContentIsClosedEverywhere(t *testing.T) {
|
||||
|
||||
// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а
|
||||
// пустая копия висела бы в панели вторым домом для понятия, которого больше нет.
|
||||
// Поверхность коллекции пользователей закрыта наглухо — все пять правил.
|
||||
//
|
||||
// Проверка стоит отдельно от соседней намеренно: та сторожит коллекции, которые
|
||||
// заводит наш шаг схемы, а эту заводит системный шаг библиотеки, и её умолчания
|
||||
// открывают владельцу записи чтение, правку и удаление собственной строки. Пока
|
||||
// узнавание жило под корнем приложения, до этой поверхности браузер не
|
||||
// дотягивался вовсе; с узнаванием по заголовку она достижима, а ключ учётной
|
||||
// записи лежит здесь обычной колонкой — правка своей записи и есть захват чужого
|
||||
// имени.
|
||||
func TestUsersCollectionSurfaceIsClosed(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Nil(t, users.ListRule, "перечисление учётных записей закрыто")
|
||||
assert.Nil(t, users.ViewRule, "чтение учётной записи закрыто")
|
||||
assert.Nil(t, users.CreateRule, "заведение учётной записи снаружи закрыто")
|
||||
assert.Nil(t, users.UpdateRule, "правка учётной записи снаружи закрыта")
|
||||
assert.Nil(t, users.DeleteRule, "удаление учётной записи снаружи закрыто")
|
||||
|
||||
// Собственные способы войти выключены там же: без этого узнавание по
|
||||
// заголовку обходится двумя запросами — завести себе запись и войти паролем.
|
||||
assert.False(t, users.PasswordAuth.Enabled, "вход по паролю выключен")
|
||||
assert.False(t, users.OTP.Enabled, "вход по одноразовому коду выключен")
|
||||
assert.False(t, users.OAuth2.Enabled, "обмен кода у внешнего провайдера выключен")
|
||||
assert.Empty(t, users.OAuth2.Providers, "настроенных провайдеров не осталось")
|
||||
}
|
||||
|
||||
func TestFormerJobsCollectionIsGone(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
|
||||
+58
-56
@@ -3,9 +3,8 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -81,64 +80,67 @@ type YandexConfig struct {
|
||||
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
|
||||
}
|
||||
|
||||
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
|
||||
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
||||
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
||||
// секрет, положенный однажды шагом, не пережил бы ротации.
|
||||
// AuthConfig — кому сервис верит на входе.
|
||||
//
|
||||
// Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком,
|
||||
// сходив к провайдеру. Секция поэтому свелась к одному ключу — перечню адресов,
|
||||
// чьему заголовку верить. Ни адресов провайдера, ни идентификатора клиента, ни
|
||||
// его секрета здесь больше нет: обменивать код не на что, и секрет ушёл из
|
||||
// конфига вместе с протоколом.
|
||||
type AuthConfig struct {
|
||||
AuthURL string `toml:"auth_url"`
|
||||
TokenURL string `toml:"token_url"`
|
||||
UserInfoURL string `toml:"user_info_url"`
|
||||
ClientID string `toml:"client_id"`
|
||||
ClientSecret string `toml:"client_secret"`
|
||||
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
|
||||
RedirectURL string `toml:"redirect_url"`
|
||||
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
|
||||
// выключается только для локального запуска по `http://localhost`, где
|
||||
// браузер такую куку не сохранит.
|
||||
SecureCookie bool `toml:"secure_cookie"`
|
||||
// TrustedProxies — адреса и подсети, чьему заголовку верят. Значение
|
||||
// сверяется с адресом самого соединения, а не с пересылаемым заголовком:
|
||||
// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
|
||||
// той же строкой, которой он обходится, не барьер вовсе.
|
||||
TrustedProxies []string `toml:"trusted_proxies"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
|
||||
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
|
||||
// открытым наружу, а узнать об этом было бы неоткуда.
|
||||
// TrustedNetworks разбирает перечень доверенных адресов.
|
||||
//
|
||||
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
|
||||
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
|
||||
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
|
||||
// молча падает целиком, вместе с ботом и воркерами.
|
||||
// Одиночный адрес принимается наравне с подсетью и превращается в подсеть на
|
||||
// один адрес: писать `/32` руками значит помнить разрядность, а перечень читает
|
||||
// человек.
|
||||
func (c AuthConfig) TrustedNetworks() ([]netip.Prefix, error) {
|
||||
networks := make([]netip.Prefix, 0, len(c.TrustedProxies))
|
||||
|
||||
for _, raw := range c.TrustedProxies {
|
||||
value := strings.TrimSpace(raw)
|
||||
|
||||
if prefix, err := netip.ParsePrefix(value); err == nil {
|
||||
networks = append(networks, prefix.Masked())
|
||||
continue
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: %s не читается как адрес или подсеть: %q", trustedProxiesKey, value)
|
||||
}
|
||||
networks = append(networks, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// trustedProxiesKey — имя ключа в отказах старта. Литерал один на файл: два
|
||||
// разошлись бы молча, и владелец искал бы в конфиге ключ, которого там нет.
|
||||
const trustedProxiesKey = "trusted_proxies"
|
||||
|
||||
// Validate проверяет, что сервису есть кому верить.
|
||||
//
|
||||
// Пустой перечень роняет старт. Он значит «не верить никому», то есть сервис,
|
||||
// поднявшийся никого не узнающим, — и молчать об этом старт не вправе: узнать о
|
||||
// такой поломке было бы неоткуда, все адреса приложения просто отвечали бы
|
||||
// отказом.
|
||||
//
|
||||
// Нечитаемая строка роняет старт по той же причине: перечень с опечаткой
|
||||
// проверяется только тем, что кто-то не смог войти.
|
||||
func (c AuthConfig) Validate() error {
|
||||
values := map[string]string{
|
||||
"auth_url": c.AuthURL,
|
||||
"token_url": c.TokenURL,
|
||||
"user_info_url": c.UserInfoURL,
|
||||
"client_id": c.ClientID,
|
||||
"client_secret": c.ClientSecret,
|
||||
"redirect_url": c.RedirectURL,
|
||||
if len(c.TrustedProxies) == 0 {
|
||||
return fmt.Errorf("auth: не заполнен ключ %s: сервису некому верить, и узнать он никого не сможет", trustedProxiesKey)
|
||||
}
|
||||
|
||||
missing := make([]string, 0, len(values))
|
||||
for name, value := range values {
|
||||
if value == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
// Названы имена ключей, а не значения: значение `client_secret` в
|
||||
// сообщение об ошибке попасть не должно, оно уедет в журнал.
|
||||
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
malformed := make([]string, 0, 4)
|
||||
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
|
||||
parsed, err := url.Parse(values[name])
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
malformed = append(malformed, name)
|
||||
}
|
||||
}
|
||||
if len(malformed) > 0 {
|
||||
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
|
||||
if _, err := c.TrustedNetworks(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -173,9 +175,9 @@ func defaultConfig() *Config {
|
||||
ObjStorageRegion: "ru-central1",
|
||||
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
SecureCookie: true,
|
||||
},
|
||||
// Умолчания у перечня доверенных адресов нет намеренно: подставленное
|
||||
// значение соврало бы ровно там, где по нему решают, кого пускать.
|
||||
Auth: AuthConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,97 +8,65 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
|
||||
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
|
||||
// исполнялась ни разу.
|
||||
// Проверка перечня доверенных адресов — единственная страховка от того, чтобы
|
||||
// сервис поднялся никого не узнающим. Узнать о такой поломке было бы неоткуда:
|
||||
// все адреса приложения просто отвечали бы отказом.
|
||||
|
||||
func validAuthConfig() AuthConfig {
|
||||
return AuthConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "secret-value",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
}
|
||||
return AuthConfig{TrustedProxies: []string{"172.16.0.0/12", "127.0.0.1"}}
|
||||
}
|
||||
|
||||
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
|
||||
if err := validAuthConfig().Validate(); err != nil {
|
||||
t.Fatalf("заполненный конфиг отвергнут: %v", err)
|
||||
t.Fatalf("заполненный перечень отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
|
||||
cases := map[string]func(*AuthConfig){
|
||||
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
|
||||
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
|
||||
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
|
||||
"client_id": func(c *AuthConfig) { c.ClientID = "" },
|
||||
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
|
||||
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
|
||||
}
|
||||
|
||||
for key, clear := range cases {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
clear(&cfg)
|
||||
|
||||
err := cfg.Validate()
|
||||
// Пустой перечень значит «не верить никому»: сервис поднялся бы, никого не
|
||||
// узнавая, и молчать об этом старт не вправе.
|
||||
func TestAuthConfigValidateRejectsEmptyList(t *testing.T) {
|
||||
err := AuthConfig{}.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
|
||||
t.Fatal("пустой перечень принят: сервис поднялся бы никого не узнающим")
|
||||
}
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Fatalf("имя ключа %s не названо: %v", key, err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(err.Error(), "trusted_proxies") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
|
||||
// и значения секрета в нём быть не должно — только имя ключа.
|
||||
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
cfg.ClientSecret = "super-secret-value"
|
||||
cfg.AuthURL = ""
|
||||
// Нечитаемая строка роняет старт: перечень с опечаткой проверяется только тем,
|
||||
// что кто-то не смог войти.
|
||||
func TestAuthConfigValidateRejectsMalformedEntry(t *testing.T) {
|
||||
for _, value := range []string{"", "not-an-address", "10.0.0.0/99", "10.0.0.256"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
cfg := AuthConfig{TrustedProxies: []string{value}}
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("отказа нет")
|
||||
t.Fatalf("строка %q принята как адрес", value)
|
||||
}
|
||||
if strings.Contains(err.Error(), "super-secret-value") {
|
||||
t.Fatalf("значение секрета попало в текст отказа: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
|
||||
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
|
||||
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
|
||||
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"без схемы": "auth.example.com/api/oidc/authorization",
|
||||
"пробел спереди": " https://auth.example.com/authorize",
|
||||
"чужая схема": "ftp://auth.example.com/authorize",
|
||||
"пустой хост": "https:///authorize",
|
||||
"не адрес вовсе": "todo: заполнить",
|
||||
}
|
||||
|
||||
for name, value := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
cfg.AuthURL = value
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("негодный адрес %q пропущен", value)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "auth_url") {
|
||||
if !strings.Contains(err.Error(), "trusted_proxies") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Одиночный адрес принимается наравне с подсетью и становится подсетью на один
|
||||
// адрес: писать разрядность руками значит её помнить, а перечень читает человек.
|
||||
func TestTrustedNetworksAcceptsBareAddress(t *testing.T) {
|
||||
networks, err := AuthConfig{TrustedProxies: []string{"10.1.2.3"}}.TrustedNetworks()
|
||||
if err != nil {
|
||||
t.Fatalf("одиночный адрес отвергнут: %v", err)
|
||||
}
|
||||
if len(networks) != 1 {
|
||||
t.Fatalf("подсетей %d, ожидалась одна", len(networks))
|
||||
}
|
||||
if !networks[0].IsSingleIP() {
|
||||
t.Fatalf("одиночный адрес стал подсетью шире одного адреса: %s", networks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -166,16 +166,15 @@ type ReplicaView struct {
|
||||
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
app := r.Group(AppRoot)
|
||||
|
||||
// Слой сессии вешается на **группу корня**, а не на перечень адресов:
|
||||
// перечень рос бы с каждым новым адресом приложения, и забытый в нём адрес
|
||||
// молча перестал бы принимать куку. Собственная поверхность хранилища под
|
||||
// слой не подпадает — часть её защищена ровно тем, что браузер заголовка сам
|
||||
// не шлёт.
|
||||
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
|
||||
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
|
||||
// иначе ушли бы телом библиотеки, мимо единой формы.
|
||||
//
|
||||
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и
|
||||
// адрес выдачи файлового токена из пространства хранилища. Область его
|
||||
// действия при этом выводится из **этого же** перечня адресного
|
||||
// пространства — см. `underIdentifiedArea`.
|
||||
app.Bind(OneErrorForm())
|
||||
app.Bind(SessionFromCookie())
|
||||
app.Bind(RequireUser(migrations.UsersCollection))
|
||||
|
||||
app.GET("/me", h.Me)
|
||||
@@ -210,7 +209,9 @@ func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
|
||||
func (h *AppHandler) Me(e *core.RequestEvent) error {
|
||||
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
||||
// человеку, а не сервису.
|
||||
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
||||
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
||||
// наружу наравне с журналом.
|
||||
return e.JSON(http.StatusOK, MeView{
|
||||
ID: e.Auth.Id,
|
||||
Name: e.Auth.GetString("name"),
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionCookieName — имя куки сессии. Имя нормативно: его смена молча
|
||||
// выкидывает всех вошедших.
|
||||
SessionCookieName = "transcriber_session"
|
||||
// stateCookieName — носитель состояния и проверочного кода PKCE. Живёт
|
||||
// один вход и убирается на возврате, каким бы тот ни был.
|
||||
stateCookieName = "transcriber_login"
|
||||
|
||||
// stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель
|
||||
// не нужен, а вечный носитель надолго фиксирует состояние.
|
||||
stateCookieMaxAge = 10 * 60
|
||||
|
||||
// exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий
|
||||
// провайдер держит обработчик возврата открытым неограниченно долго, и «медленный»
|
||||
// становится неотличим от «отказал».
|
||||
exchangeTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// AuthHandler ведёт вход, возврат от провайдера и выход.
|
||||
//
|
||||
// Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11.
|
||||
// Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом,
|
||||
// поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же
|
||||
// роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные
|
||||
// записи заводит хранилище и они видны в панели.
|
||||
type AuthHandler struct {
|
||||
app core.App
|
||||
logger *slog.Logger
|
||||
authURL string
|
||||
redirectURL string
|
||||
clientID string
|
||||
secureCookie bool
|
||||
|
||||
// storageMux — роутер хранилища, через который идёт обмен кода. Собирается
|
||||
// один раз: сборка вешает обработчики на само приложение и без
|
||||
// идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним.
|
||||
// Собранный на каждый вход, он копил бы их без предела — и копил бы по
|
||||
// запросу анонима, потому что обмен исполняется раньше обращения к
|
||||
// провайдеру.
|
||||
storageMux http.Handler
|
||||
storageMuxOnce sync.Once
|
||||
storageMuxErr error
|
||||
}
|
||||
|
||||
type AuthHandlerConfig struct {
|
||||
AuthURL string
|
||||
RedirectURL string
|
||||
ClientID string
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &AuthHandler{
|
||||
app: app,
|
||||
logger: logger,
|
||||
authURL: cfg.AuthURL,
|
||||
redirectURL: cfg.RedirectURL,
|
||||
clientID: cfg.ClientID,
|
||||
secureCookie: cfg.SecureCookie,
|
||||
}
|
||||
}
|
||||
|
||||
// Register вешает адреса входа вне пространства `/api`: оно поделено с
|
||||
// собственными адресами хранилища.
|
||||
func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// Продление сессии закрывается на всём роутере: адрес приносит хранилище
|
||||
// своим, и перехватить его можно только слоем.
|
||||
r.Bind(BlockSessionRefresh())
|
||||
|
||||
// Адреса вешаются группой корня, а не литералами: корень объявлен единой
|
||||
// точкой адресного пространства, и порознь записанный он разошёлся бы с ней
|
||||
// молча — раздача приложения начала бы отдавать разметку на возврат от
|
||||
// провайдера, без единой ошибки в журнале.
|
||||
auth := r.Group(AuthRoot)
|
||||
|
||||
auth.GET("/login", h.Login)
|
||||
auth.GET("/callback", h.Callback)
|
||||
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
|
||||
// чужой ссылке.
|
||||
//
|
||||
// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
|
||||
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
|
||||
// значение годным. Требования сессии при этом нет: выход без неё убирает
|
||||
// куку и молчит.
|
||||
auth.POST("/logout", h.Logout).Bind(SessionFromCookie())
|
||||
}
|
||||
|
||||
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
|
||||
// PKCE у браузера.
|
||||
func (h *AuthHandler) Login(e *core.RequestEvent) error {
|
||||
state := security.RandomString(32)
|
||||
verifier := security.RandomString(43)
|
||||
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: state + ":" + verifier,
|
||||
Path: "/",
|
||||
MaxAge: stateCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", h.clientID)
|
||||
query.Set("redirect_uri", h.redirectURL)
|
||||
query.Set("scope", "openid profile email")
|
||||
query.Set("state", state)
|
||||
query.Set("code_challenge", security.S256Challenge(verifier))
|
||||
query.Set("code_challenge_method", "S256")
|
||||
|
||||
separator := "?"
|
||||
if strings.Contains(h.authURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode())
|
||||
}
|
||||
|
||||
// Callback принимает возврат от провайдера, сверяет состояние и меняет код на
|
||||
// сессию средствами хранилища.
|
||||
func (h *AuthHandler) Callback(e *core.RequestEvent) error {
|
||||
// Носитель убирается всегда — и на успехе, и на отказе, — и убирается
|
||||
// **до** записи ответа. Отложенная уборка не работает вовсе: заголовки
|
||||
// фиксируются в момент, когда ответ начинают писать, и позднейшая правка их
|
||||
// карты до браузера не доезжает. Состояние одноразовое ровно этим: пока
|
||||
// носитель жив, переигранный возврат проходит сверку.
|
||||
h.clearStateCookie(e)
|
||||
|
||||
query := e.Request.URL.Query()
|
||||
|
||||
// Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка
|
||||
// сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе
|
||||
// обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит».
|
||||
if providerError := query.Get("error"); providerError != "" {
|
||||
h.logger.Debug("Login rejected by provider",
|
||||
"reason", knownProviderError(providerError), "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
state, verifier, err := h.readStateCookie(e)
|
||||
if err != nil {
|
||||
h.logger.Debug("Login state is missing or malformed",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
if query.Get("state") != state {
|
||||
h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
code := query.Get("code")
|
||||
if code == "" {
|
||||
h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
token, err := h.exchange(e.Request.Context(), code, verifier)
|
||||
if err != nil {
|
||||
// Отказ обмена — уже про сервис и его связь с провайдером, поэтому
|
||||
// уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель
|
||||
// входа.
|
||||
h.logger.Error("Failed to exchange provider code",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
h.setSessionCookie(e, token)
|
||||
|
||||
return e.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// Logout обесценивает выданные учётной записи сессии и убирает куку.
|
||||
//
|
||||
// Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке
|
||||
// выход, разошедшийся с одновременным входом, оставил бы годную сессию, а
|
||||
// человек был бы уверен, что вышел.
|
||||
func (h *AuthHandler) Logout(e *core.RequestEvent) error {
|
||||
if e.Auth != nil {
|
||||
// Ключ токенов обновляется у свежей записи: между чтением и записью
|
||||
// могла пройти чужая правка, и полное сохранение устаревшей записи
|
||||
// затёрло бы её.
|
||||
record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to load account for logout", "error", err, "transport", "http")
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
|
||||
}
|
||||
|
||||
record.RefreshTokenKey()
|
||||
if err := h.app.Save(record); err != nil {
|
||||
h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http")
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
|
||||
}
|
||||
}
|
||||
|
||||
h.clearSessionCookie(e)
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не
|
||||
// идёт: роутер поднимается тот же, что обслуживает внешние запросы.
|
||||
func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
|
||||
defer cancel()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"provider": pbrepo.ProviderName,
|
||||
"code": code,
|
||||
"codeVerifier": verifier,
|
||||
"redirectURL": h.redirectURL,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build exchange request: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/collections/users/auth-with-oauth2",
|
||||
strings.NewReader(string(body)),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build exchange request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
// Адрес запросу нужен, хотя запрос внутрипроцессный и наружу не идёт.
|
||||
//
|
||||
// Ограничитель частоты хранилища ключует клиента адресом, а у собранного
|
||||
// руками запроса его нет вовсе — и вырожденное значение библиотека отдаёт не
|
||||
// пустой строкой, а литералом. Её собственный страж «пустой ключ пропускаем»
|
||||
// такое значение не ловит, поэтому **все** внутренние обмены кода схлопнулись
|
||||
// бы в один счётчик: третий вход в пределах трёх секунд — чей угодно —
|
||||
// получал бы отказ ограничителя, неотличимый от настоящего отказа провайдера.
|
||||
//
|
||||
// Прежде этого не случалось: ограничитель был выключен целиком. Он включается
|
||||
// вместе с правилом под корнем приложения, и цена названа здесь.
|
||||
request.RemoteAddr = "127.0.0.1:0"
|
||||
|
||||
handler, err := h.storageHandler()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
// Тело ответа наружу не выносится: в нём приезжает описание отказа
|
||||
// провайдера, а оно принадлежит журналу, а не человеку.
|
||||
return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to read exchange response: %w", err)
|
||||
}
|
||||
if response.Token == "" {
|
||||
return "", errors.New("exchange response carries no session")
|
||||
}
|
||||
|
||||
return response.Token, nil
|
||||
}
|
||||
|
||||
// knownProviderError приводит причину отказа к перечню известных.
|
||||
//
|
||||
// Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без
|
||||
// приведения аноним пишет в журнал что угодно и сколько угодно — предел один,
|
||||
// размер заголовков. Журнал же единственное место, где наблюдаются инварианты о
|
||||
// молчаливой потере задачи, и вытеснять его чужим текстом нельзя.
|
||||
//
|
||||
// Приём тот же, каким расширение записи приводится к перечню форматов.
|
||||
func knownProviderError(value string) string {
|
||||
switch value {
|
||||
case "access_denied", "invalid_request", "invalid_scope", "server_error",
|
||||
"temporarily_unavailable", "unauthorized_client", "unsupported_response_type",
|
||||
"interaction_required", "login_required", "consent_required":
|
||||
return value
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// storageHandler собирает роутер хранилища один раз и отдаёт его всем
|
||||
// последующим обменам.
|
||||
func (h *AuthHandler) storageHandler() (http.Handler, error) {
|
||||
h.storageMuxOnce.Do(func() {
|
||||
router, err := apis.NewRouter(h.app)
|
||||
if err != nil {
|
||||
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
|
||||
return
|
||||
}
|
||||
mux, err := router.BuildMux()
|
||||
if err != nil {
|
||||
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
|
||||
return
|
||||
}
|
||||
h.storageMux = mux
|
||||
})
|
||||
|
||||
return h.storageMux, h.storageMuxErr
|
||||
}
|
||||
|
||||
func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) {
|
||||
cookie, err := e.Request.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("login state cookie is missing: %w", err)
|
||||
}
|
||||
|
||||
state, verifier, found := strings.Cut(cookie.Value, ":")
|
||||
if !found || state == "" || verifier == "" {
|
||||
return "", "", errors.New("login state cookie is malformed")
|
||||
}
|
||||
|
||||
return state, verifier, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: pbrepo.SessionDuration,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -16,14 +17,14 @@ import (
|
||||
)
|
||||
|
||||
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
|
||||
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
|
||||
// называется пришедший, кому верят и какие адреса остаются открытыми.
|
||||
|
||||
// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
|
||||
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
|
||||
func TestApiRequiresSession(t *testing.T) {
|
||||
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
|
||||
// отказ и ничего не заводит.
|
||||
func TestApiRequiresIdentity(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
t.Run("приём записи без сессии", func(t *testing.T) {
|
||||
t.Run("приём записи неузнанным", func(t *testing.T) {
|
||||
req := createMultipartRequest(t, "test.mp3", []byte("audio"))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -43,7 +44,7 @@ func TestApiRequiresSession(t *testing.T) {
|
||||
assert.Empty(t, jobs)
|
||||
})
|
||||
|
||||
t.Run("опрос готовности без сессии", func(t *testing.T) {
|
||||
t.Run("карточка записи неузнанному", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -55,9 +56,9 @@ func TestApiRequiresSession(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
|
||||
// перебирается список заведённых задач.
|
||||
func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
|
||||
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
|
||||
// не перебирается список заведённых задач.
|
||||
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
@@ -78,320 +79,322 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
|
||||
assert.Equal(t, missing.Code, existing.Code)
|
||||
}
|
||||
|
||||
// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
|
||||
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
|
||||
// роутер с теми же двумя адресами.
|
||||
func TestOpenEndpointsStayOpen(t *testing.T) {
|
||||
// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**.
|
||||
//
|
||||
// Два запроса подряд с одним значением заголовка: учётная запись заводится
|
||||
// первым и находится вторым, а в хранилище её строка одна.
|
||||
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const login = "newcomer"
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
|
||||
assert.Equal(t, before+1, countAccounts(t, env),
|
||||
"второе обращение завело вторую запись: архив разъехался бы между ними")
|
||||
assert.Equal(t, first.Body.String(), second.Body.String(),
|
||||
"второе обращение попало в другую учётную запись")
|
||||
}
|
||||
|
||||
// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же
|
||||
// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем.
|
||||
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, "intruder")
|
||||
req.RemoteAddr = untrustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"заголовок с недоверенного адреса завёл учётную запись")
|
||||
}
|
||||
|
||||
// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**.
|
||||
//
|
||||
// Перечень собственных адресов входа хранилища: каждый отвечает отказом и
|
||||
// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один
|
||||
// из них работает, узнавание по заголовку обходится двумя запросами.
|
||||
func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const usersRoot = "/api/collections/users"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "завести учётную запись самому",
|
||||
path: usersRoot + "/records",
|
||||
body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`,
|
||||
},
|
||||
{
|
||||
name: "вход по паролю",
|
||||
path: usersRoot + "/auth-with-password",
|
||||
body: `{"identity":"person@example.com","password":"whatever"}`,
|
||||
},
|
||||
{
|
||||
name: "обмен кода у внешнего провайдера",
|
||||
path: usersRoot + "/auth-with-oauth2",
|
||||
body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`,
|
||||
},
|
||||
{
|
||||
name: "вход по одноразовому коду",
|
||||
path: usersRoot + "/auth-with-otp",
|
||||
body: `{"otpId":"whatever","password":"whatever"}`,
|
||||
},
|
||||
{
|
||||
name: "запрос одноразового кода",
|
||||
path: usersRoot + "/request-otp",
|
||||
body: `{"email":"person@example.com"}`,
|
||||
},
|
||||
{
|
||||
name: "восстановление пароля",
|
||||
path: usersRoot + "/request-password-reset",
|
||||
body: `{"email":"person@example.com"}`,
|
||||
},
|
||||
{
|
||||
name: "продление сессии",
|
||||
path: usersRoot + "/auth-refresh",
|
||||
body: `{}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
||||
"адрес %s ответил успехом: собственный вход хранилища открыт", c.path)
|
||||
assert.NotContains(t, w.Body.String(), `"token"`,
|
||||
"адрес %s выдал значение доступа", c.path)
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"адрес %s изменил число учётных записей", c.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт.
|
||||
//
|
||||
// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает
|
||||
// владельцу записи правку собственной строки. Пока узнавание жило под корнем
|
||||
// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она
|
||||
// достижима, и правка своей записи была бы захватом чужого имени: поставил себе
|
||||
// чужой логин — и первое обращение настоящего его владельца попало бы в твою
|
||||
// запись вместе со всем архивом.
|
||||
func TestUserRecordCannotBeEditedFromOutside(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла")
|
||||
|
||||
after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField),
|
||||
"ключ учётной записи переписан снаружи")
|
||||
}
|
||||
|
||||
// TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто.
|
||||
// Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те
|
||||
// значения, которыми довольно назваться.
|
||||
func TestUserRecordsCannotBeListed(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
assert.NotContains(t, w.Body.String(), env.login)
|
||||
}
|
||||
|
||||
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
|
||||
// ничего не заводит.
|
||||
//
|
||||
// Пустое значение здесь не крайний случай, а штатное поведение прокси: там, где
|
||||
// он никого не назвал, заголовок приходит пустым. Без этой проверки все
|
||||
// неназванные собрались бы в одну учётную запись с общим архивом.
|
||||
func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
cases := map[string]string{
|
||||
"пустое значение": "",
|
||||
"одни пробелы": " ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1),
|
||||
}
|
||||
|
||||
for name, value := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, value)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env), "вырожденное значение завело учётную запись")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoLoginHeadersIdentifyNobody: запрос с двумя значениями заголовка не
|
||||
// узнаёт никого.
|
||||
//
|
||||
// Прокси, настроенный **добавлять** заголовок вместо замены, оставляет рядом со
|
||||
// своим значением присланное анонимом. Умолчание «берём первое» отдало бы вход
|
||||
// анониму, а «берём последнее» зависело бы от порядка, которым распоряжается не
|
||||
// сервис.
|
||||
func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Add(LoginHeader, "intruder")
|
||||
req.Header.Add(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env))
|
||||
}
|
||||
|
||||
// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает
|
||||
// заголовок, а протухший узнаванию не мешает.
|
||||
//
|
||||
// Первая половина защищает владельца панели: подмена его учётной записью
|
||||
// пользователя отобрала бы у него панель посреди работы. Вторая — обычного
|
||||
// человека: негодный токен, оставшийся в браузере, не должен запирать его
|
||||
// снаружи.
|
||||
func TestStorageTokenBeatsHeader(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
stranger, strangerLogin := newSecondAccount(t, env.app)
|
||||
token, err := stranger.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("годный токен побеждает", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), stranger.Id,
|
||||
"заголовок победил предъявленный токен")
|
||||
_ = strangerLogin
|
||||
})
|
||||
|
||||
t.Run("протухший токен узнаванию не мешает", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set("Authorization", "not-a-token")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), env.account.Id)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты
|
||||
// неузнанному, а заголовок на них учётной записи не заводит.
|
||||
//
|
||||
// Вторая половина важнее первой: узнавание сужено до области приложения именно
|
||||
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
|
||||
// такой запрос с новым именем — записи в неё.
|
||||
func TestOpenAddressesDoNotIdentify(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.GET("/health", func(e *core.RequestEvent) error {
|
||||
appHandler := NewAppHandler(nil, nil, nil, nil, nil)
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil))
|
||||
|
||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.GET("/metrics", func(e *core.RequestEvent) error {
|
||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
||||
return e.String(http.StatusOK, "# metrics")
|
||||
})
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, path := range []string{"/health", "/metrics"} {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{HealthPath, MetricsPath} {
|
||||
anonymous := httptest.NewRecorder()
|
||||
mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
|
||||
|
||||
// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
|
||||
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
|
||||
// вошедших не выкидывает.
|
||||
func TestSessionSurvivesRestart(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := httptest.NewRecorder()
|
||||
env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||
require.Equal(t, http.StatusCreated, before.Code)
|
||||
|
||||
// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
|
||||
// поверх прежнего каталога данных.
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
env.handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
|
||||
// ненайденную задачу, а не про отсутствующую сессию.
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
|
||||
// сессии, а не только убирает куку.
|
||||
func TestLogoutClosesAccess(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
env.handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
logoutResponse := httptest.NewRecorder()
|
||||
mux.ServeHTTP(logoutResponse, logout)
|
||||
require.Equal(t, http.StatusOK, logoutResponse.Code)
|
||||
|
||||
// Куку выход убирает.
|
||||
assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
|
||||
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
|
||||
// себе не даёт: унесённое значение работало бы до истечения срока.
|
||||
after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
afterResponse := httptest.NewRecorder()
|
||||
mux.ServeHTTP(afterResponse, after)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
|
||||
}
|
||||
|
||||
// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
|
||||
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
|
||||
func TestLogoutWhenAccountIsGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
|
||||
// учётной записи в панели.
|
||||
require.NoError(t, env.app.Delete(env.account))
|
||||
|
||||
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, logout)
|
||||
|
||||
// Записи нет — проверка сессии её не находит, и до обесценивания дело не
|
||||
// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
|
||||
// не осталось, потому что не осталось учётной записи.
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
|
||||
after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
afterResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(afterResponse, after)
|
||||
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
|
||||
}
|
||||
|
||||
// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
|
||||
func TestLogoutWithoutSession(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
}
|
||||
|
||||
// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
|
||||
// у браузера.
|
||||
func TestLoginRedirectsToProvider(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
location := w.Result().Header.Get("Location")
|
||||
assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
|
||||
assert.Contains(t, location, "code_challenge_method=S256")
|
||||
assert.Contains(t, location, "client_id=transcriber")
|
||||
|
||||
// Носитель состояния несёт те же признаки защиты, что и кука сессии.
|
||||
stateCookie := w.Result().Header.Get("Set-Cookie")
|
||||
assert.Contains(t, stateCookie, stateCookieName)
|
||||
assert.Contains(t, stateCookie, "HttpOnly")
|
||||
assert.Contains(t, stateCookie, "Secure")
|
||||
assert.Contains(t, stateCookie, "SameSite=Lax")
|
||||
}
|
||||
|
||||
// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
|
||||
// открывает и учётной записи не заводит.
|
||||
func TestCallbackRejectsForeignState(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
accountsBefore, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cookie *http.Cookie
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "состояния не выдавали вовсе",
|
||||
cookie: nil,
|
||||
query: "?code=whatever&state=foreign",
|
||||
},
|
||||
{
|
||||
name: "состояние не совпало с выданным",
|
||||
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
|
||||
query: "?code=whatever&state=foreign",
|
||||
},
|
||||
{
|
||||
name: "провайдер вернул отказ",
|
||||
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
|
||||
query: "?error=access_denied&state=issued",
|
||||
},
|
||||
withHeader := httptest.NewRecorder()
|
||||
mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby"))
|
||||
assert.Equal(t, http.StatusOK, withHeader.Code,
|
||||
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
|
||||
if tc.cookie != nil {
|
||||
req.AddCookie(tc.cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
accountsAfter, err := env.app.FindAllRecords("users")
|
||||
records, err := app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, accountsAfter, len(accountsBefore))
|
||||
|
||||
// Носитель убирается и на отказном возврате: иначе состояние
|
||||
// осталось бы годным для новой попытки.
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
|
||||
})
|
||||
}
|
||||
assert.Empty(t, records, "обращение к открытому адресу завело учётную запись")
|
||||
}
|
||||
|
||||
// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
|
||||
func TestHeaderBeatsCookie(t *testing.T) {
|
||||
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
|
||||
//
|
||||
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
|
||||
// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся
|
||||
// кука вернула бы и это.
|
||||
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
|
||||
req.Header.Set("Authorization", env.session)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Прошёл заголовок: иначе негодная кука дала бы отказ.
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
|
||||
}
|
||||
|
||||
// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
|
||||
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
|
||||
// двумя запросами.
|
||||
func TestSelfServiceAccountsAreClosed(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, w.Code)
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("вход паролем недоступен", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
|
||||
// ревью дизайна: не печатается ничто, что даёт доступ.
|
||||
// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не
|
||||
// печатается ничто, что даёт доступ.
|
||||
//
|
||||
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
|
||||
// другим ключом, поиск по ключу не разбудил бы.
|
||||
func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
|
||||
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
|
||||
// довольно назваться, чтобы стать этим человеком.
|
||||
func TestIdentityValuesAreNotLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
@@ -401,59 +404,34 @@ func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
|
||||
journal := env.journal.String()
|
||||
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
|
||||
|
||||
assert.NotContains(t, journal, env.session,
|
||||
"значение сессии в журнале: строка стала бы ключом к чужому доступу")
|
||||
assert.NotContains(t, journal, env.login,
|
||||
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
|
||||
assert.NotContains(t, journal, env.account.Email(),
|
||||
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
|
||||
}
|
||||
|
||||
// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
|
||||
// приведении настроек провайдера к конфигу.
|
||||
func TestProviderSecretIsNotLogged(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
|
||||
// виден **адресом пира**, а не значением заголовка.
|
||||
//
|
||||
// Без этой строки владелец, у которого никто не может войти, не отличит своей
|
||||
// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не
|
||||
// ставит) — а это разные поломки в разных местах.
|
||||
func TestUntrustedPeerIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const secret = "super-secret-client-value"
|
||||
const intruder = "intruder-login-value"
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: secret,
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, intruder)
|
||||
req.RemoteAddr = untrustedPeer
|
||||
|
||||
// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
|
||||
// журнале прошла бы на невыполненной работе.
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, secret, provider.ClientSecret)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
|
||||
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
|
||||
// не переписывается.
|
||||
func TestProviderSecretRotationReachesStorage(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
settings := pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "first-secret",
|
||||
}
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
|
||||
|
||||
settings.ClientSecret = "rotated-secret"
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "rotated-secret", provider.ClientSecret)
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "203.0.113.9", "адреса пира в журнале нет: поломку не отличить")
|
||||
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
|
||||
@@ -469,10 +447,10 @@ func TestRecordFileIsProtected(t *testing.T) {
|
||||
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
|
||||
}
|
||||
|
||||
// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
|
||||
// TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а
|
||||
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
|
||||
// ссылке.
|
||||
func TestRecordFileNeedsSession(t *testing.T) {
|
||||
func TestRecordFileNeedsToken(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
@@ -494,9 +472,18 @@ func TestRecordFileNeedsSession(t *testing.T) {
|
||||
// своего существования. До пометки поля защищённым эта же ссылка отдавала
|
||||
// содержимое кому угодно — знание ссылки и было доступом.
|
||||
assert.Equal(t, http.StatusNotFound, anonymous.Code,
|
||||
"ссылка отдала файл без сессии: знание ссылки снова стало доступом")
|
||||
"ссылка отдала файл без токена: знание ссылки снова стало доступом")
|
||||
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||
|
||||
// А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен
|
||||
// файла → ссылка» обязан работать целиком, иначе файл записи недостижим для
|
||||
// браузера вовсе.
|
||||
withToken := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(withToken,
|
||||
httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil))
|
||||
require.Equal(t, http.StatusOK, withToken.Code)
|
||||
assert.Equal(t, "audio content", withToken.Body.String())
|
||||
|
||||
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
|
||||
fileRepo := pbrepo.NewFileRepository(env.app)
|
||||
reader, err := fileRepo.Open(files[0].Id)
|
||||
@@ -511,29 +498,144 @@ func TestRecordFileNeedsSession(t *testing.T) {
|
||||
assert.Equal(t, "audio content", string(content))
|
||||
}
|
||||
|
||||
// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
|
||||
// умолчанием библиотеки в пять суток.
|
||||
//
|
||||
// Назначается он приведением настроек при подъёме, а не шагом схемы: применённый
|
||||
// шаг не переписывается, и число, положенное туда, разошлось бы со сроком жизни
|
||||
// куки при первой же правке.
|
||||
func TestSessionLifetimeIsAssigned(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
// countAccounts — сколько учётных записей лежит в хранилище. Проверки судят
|
||||
// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по
|
||||
// ответу обработчика они неразличимы.
|
||||
func countAccounts(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
records, err := env.app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
|
||||
"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "local-test-secret",
|
||||
}))
|
||||
|
||||
users, err = app.FindCollectionByNameOrId("users")
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты
|
||||
// запрос не заводит учётной записи.
|
||||
//
|
||||
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
|
||||
// ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать
|
||||
// запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили
|
||||
// пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями
|
||||
// не удаляется, а мусорная растёт в той же единственной базе.
|
||||
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
|
||||
// потолок раньше, чем начнутся новые имена.
|
||||
for range appRateMaxRequests + 5 {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
||||
}
|
||||
|
||||
rejected := 0
|
||||
for i := range 20 {
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest(http.MethodGet, "/app/me", nil),
|
||||
fmt.Sprintf("newcomer-%d", i)))
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
rejected++
|
||||
}
|
||||
}
|
||||
|
||||
require.Positive(t, rejected, "ограничитель не сработал — проверке не на чем сработать")
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"отвергнутый ограничителем запрос завёл учётную запись: узнавание стоит раньше ограничителя")
|
||||
}
|
||||
|
||||
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
|
||||
//
|
||||
// Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а
|
||||
// прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение
|
||||
// заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим
|
||||
// человеком.
|
||||
func TestAccountCreationIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const login = "brand-new-person"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "Account created from login header", "заведение не оставило строки")
|
||||
assert.NotContains(t, journal, login, "значение заголовка уехало в журнал")
|
||||
|
||||
// Второе обращение новой строки не прибавляет: заводится запись однажды.
|
||||
before := strings.Count(journal, "Account created from login header")
|
||||
|
||||
again := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
require.Equal(t, http.StatusOK, again.Code)
|
||||
|
||||
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
|
||||
"повторное обращение отчиталось заведением")
|
||||
}
|
||||
|
||||
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
|
||||
//
|
||||
// Два значения заголовка означают прокси, который его добавляет вместо замены,
|
||||
// — модель угроз называет это главным барьером. Отладочным уровнем такая
|
||||
// поломка в бою не видна вовсе: боевой уровень журнала информационный.
|
||||
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Add(LoginHeader, "intruder")
|
||||
req.Header.Add(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "level=WARN", "поломка контура записана уровнем, невидимым в бою")
|
||||
assert.Contains(t, journal, "more than one login header")
|
||||
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути
|
||||
// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
||||
//
|
||||
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя
|
||||
// то, что сломано не у него.
|
||||
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
// Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так
|
||||
// же, как она перестанет работать при отказе хранилища.
|
||||
users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
|
||||
users.RemoveIndex("idx_users_provider_login")
|
||||
users.Fields.RemoveByName(migrations.ProviderLoginField)
|
||||
require.NoError(t, env.app.Save(users))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody"))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
|
||||
"отказ хранилища выдан за «вас не узнали»")
|
||||
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
|
||||
}
|
||||
|
||||
// TestFormerAuthRootServesMarkup — прежние адреса входа отдают разметку.
|
||||
//
|
||||
// Корень `/auth` снят из перечня адресного пространства, и путь под ним стал
|
||||
// обычным путём вне корней. Проверка сторожит именно это: вернувшийся корень
|
||||
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
|
||||
// поведение.
|
||||
func TestFormerAuthRootServesMarkup(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
res := env.get("/auth/login")
|
||||
|
||||
assert.Equal(t, http.StatusOK, res.Code)
|
||||
assert.Contains(t, res.Body.String(), "приложение")
|
||||
}
|
||||
|
||||
@@ -96,15 +96,19 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
|
||||
|
||||
case errors.Is(err, contract.ErrUnauthorized):
|
||||
// Не «требуется вход»: своего входа у сервиса нет, и уводить человека
|
||||
// некуда. Сообщение называет то, что произошло на самом деле, — сервис
|
||||
// не узнал пришедшего, — и приложение показывает его как есть, своего
|
||||
// словаря текстов под коды ответа не заводя.
|
||||
return http.StatusUnauthorized, ErrorBody{
|
||||
Code: CodeUnauthorized,
|
||||
Message: "Требуется вход",
|
||||
Message: "Сервис вас не узнал",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrOwnerRequired):
|
||||
return http.StatusForbidden, ErrorBody{
|
||||
Code: CodeForbidden,
|
||||
Message: "У вашей сессии нет учётной записи пользователя",
|
||||
Message: "У предъявителя нет учётной записи пользователя",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,9 +218,13 @@ func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
|
||||
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberRequireUser",
|
||||
// Сразу после слоя, который читает предъявленный токен: раньше него
|
||||
// `e.Auth` ещё пуст, и всякий запрос получал бы отказ.
|
||||
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
|
||||
// Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий
|
||||
// запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за
|
||||
// ограничителем частоты — см. `TrustedHeaderIdentity`.
|
||||
//
|
||||
// Предел тела библиотеки идёт следом (−990), и это обязательно: отказ
|
||||
// неузнанному наступает **до** чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 2,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return fail(e, contract.ErrUnauthorized)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
)
|
||||
|
||||
// Заголовки, которыми обратный прокси называет пришедшего.
|
||||
//
|
||||
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же
|
||||
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама
|
||||
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена
|
||||
// соседним сервисам, и настройкой они не делаются: второе место, где их можно
|
||||
// написать неверно, выгоды не даёт.
|
||||
const (
|
||||
LoginHeader = "Remote-User"
|
||||
NameHeader = "Remote-Name"
|
||||
EmailHeader = "Remote-Email"
|
||||
)
|
||||
|
||||
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла.
|
||||
//
|
||||
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь
|
||||
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе —
|
||||
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
|
||||
const FileTokenPath = StorageRoot + "/files/token"
|
||||
|
||||
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
|
||||
//
|
||||
// # Область
|
||||
//
|
||||
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не
|
||||
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но
|
||||
// узнаёт он **только на объявленной области**: корень приложения плюс этот
|
||||
// адрес. Область выводится из перечня адресного пространства, а не пишется
|
||||
// вторым списком.
|
||||
//
|
||||
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной
|
||||
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища
|
||||
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение
|
||||
// настоящего владельца этого имени попало бы в чужую запись вместе со всем
|
||||
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
|
||||
// два барьера здесь именно потому, что прежний был один и держался на
|
||||
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
|
||||
//
|
||||
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
|
||||
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
|
||||
// базе, а первый такой запрос с новым именем — записи в неё.
|
||||
//
|
||||
// # Кто побеждает
|
||||
//
|
||||
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
|
||||
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
|
||||
// учётной записью пользователя отобрала бы у него панель посреди работы.
|
||||
// Протухший и негодный токен предъявленными не считаются: библиотека их не
|
||||
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
|
||||
//
|
||||
// # Чего слой не делает
|
||||
//
|
||||
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты
|
||||
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто
|
||||
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием
|
||||
// учётной записи на адресах приложения.
|
||||
//
|
||||
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
|
||||
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
|
||||
// легла база.
|
||||
func TrustedHeaderIdentity(
|
||||
app core.App,
|
||||
mounts []Mount,
|
||||
trusted []netip.Prefix,
|
||||
logger *slog.Logger,
|
||||
) *hook.Handler[*core.RequestEvent] {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberTrustedHeaderIdentity",
|
||||
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
|
||||
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
|
||||
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
|
||||
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
|
||||
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
|
||||
// убираются ничем.
|
||||
//
|
||||
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
|
||||
// узнавание (−999) → требование учётной записи (−998) → предел тела
|
||||
// (−990). Требование стоит перед пределом тела намеренно: отказ
|
||||
// неузнанному обязан наступать до чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// Более одного значения — не выбор, а отказ. Прокси, настроенный
|
||||
// добавлять заголовок вместо замены, оставляет рядом со своим
|
||||
// значением присланное анонимом, и умолчание «берём первое» отдало
|
||||
// бы вход анониму.
|
||||
values := e.Request.Header.Values(LoginHeader)
|
||||
if len(values) != 1 {
|
||||
if len(values) > 1 {
|
||||
// Уровень предупреждающий: два значения означают прокси,
|
||||
// который заголовок **добавляет** вместо замены, — то есть
|
||||
// ровно ту поломку контура, которую модель угроз называет
|
||||
// главной. Отладочным уровнем она в бою не видна вовсе:
|
||||
// боевой уровень журнала информационный.
|
||||
logger.Warn("Request carries more than one login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
} else {
|
||||
// Заголовка нет вовсе. В этом контуре это значит, что прокси
|
||||
// никого не назвал; строка отладочная, потому что случай
|
||||
// штатный — так выглядит и человек, которому провайдер
|
||||
// отказал.
|
||||
logger.Debug("Request carries no login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
peer, ok := peerAddress(e.Request.RemoteAddr)
|
||||
if !ok || !isTrusted(trusted, peer) {
|
||||
// Уровень предупреждающий, а не отладочный, и это решение о
|
||||
// цене. Заголовок с недоверенного адреса в этом контуре — не
|
||||
// рутина: контейнер портов наружу не публикует, снаружи всё
|
||||
// приходит прокси, то есть с доверенного адреса. Значит либо
|
||||
// перечень задан неверно, либо кто-то оказался внутри сети — и
|
||||
// то и другое владелец обязан увидеть.
|
||||
//
|
||||
// Значение заголовка при этом в журнал не идёт: оно целиком
|
||||
// задаётся тем, кто шлёт запрос. Адрес пира идёт — по нему
|
||||
// видно, чья это поломка: своя (перечень) или контура (прокси
|
||||
// заголовка не ставит).
|
||||
logger.Warn("Login header came from an untrusted peer",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{
|
||||
Login: values[0],
|
||||
Name: e.Request.Header.Get(NameHeader),
|
||||
Email: e.Request.Header.Get(EmailHeader),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) {
|
||||
// Негодный логин — это негодный ввод, а не поломка сервиса:
|
||||
// пустой заголовок прокси шлёт штатно там, где никого не
|
||||
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
|
||||
// неузнанным.
|
||||
logger.Debug("Login header value is not acceptable",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
logger.Error("Failed to resolve account by login header",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return fail(e, err)
|
||||
}
|
||||
|
||||
if created {
|
||||
// Заведение учётной записи — событие, и владелец обязан его
|
||||
// видеть: иначе «никто не заходил» неотличимо от «завелось
|
||||
// двадцать», а прокси, пропустивший чужой заголовок, не
|
||||
// оставляет следа вовсе. Убрать заведённую запись потом нечем —
|
||||
// учётная запись с записями не удаляется.
|
||||
//
|
||||
// Значение заголовка в строку не идёт: им довольно назваться,
|
||||
// чтобы стать этим человеком. Идут адрес пира и идентификатор
|
||||
// записи — оба выданы не спрашивающим.
|
||||
logger.Info("Account created from login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"account_id", record.Id,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
|
||||
e.Auth = record
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути.
|
||||
//
|
||||
// Область — корень приложения из перечня адресного пространства плюс адрес
|
||||
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень
|
||||
// объявлен единой точкой адресного пространства, и записанный здесь второй раз
|
||||
// он разошёлся бы с ней молча.
|
||||
func underIdentifiedArea(mounts []Mount, requestPath string) bool {
|
||||
if requestPath == FileTokenPath {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, mount := range mounts {
|
||||
if mount.Path == AppRoot {
|
||||
return mount.Covers(requestPath)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// peerAddress достаёт адрес того, кто открыл соединение.
|
||||
//
|
||||
// Берётся именно он, а не пересылаемый заголовок: значением пересылаемого
|
||||
// распоряжается тот, кто шлёт запрос, и барьер, подделываемый той же строкой,
|
||||
// которой он обходится, не барьер вовсе.
|
||||
func peerAddress(remoteAddr string) (netip.Addr, bool) {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
// Адрес без порта — законная форма у некоторых слушателей.
|
||||
host = remoteAddr
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// Адрес IPv4, приехавший в оболочке IPv6, сверяется с перечнем как IPv4:
|
||||
// иначе `127.0.0.1` в перечне не совпал бы с `::ffff:127.0.0.1` у пира.
|
||||
return addr.Unmap(), true
|
||||
}
|
||||
|
||||
func isTrusted(trusted []netip.Prefix, peer netip.Addr) bool {
|
||||
for _, network := range trusted {
|
||||
if network.Contains(peer) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func TestList_ShowsOnlyOwnRecords(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: stranger})
|
||||
asUser(req, stranger)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
|
||||
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
|
||||
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.
|
||||
|
||||
// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
|
||||
// считая обращения: по счётчику видно, дошло ли до сети вообще.
|
||||
type fakeProvider struct {
|
||||
server *httptest.Server
|
||||
tokenHits int
|
||||
failToken bool
|
||||
subject string
|
||||
emailValue string
|
||||
}
|
||||
|
||||
func newFakeProvider(t *testing.T) *fakeProvider {
|
||||
t.Helper()
|
||||
|
||||
provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}
|
||||
|
||||
write := func(w http.ResponseWriter, body string) {
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Errorf("подставной провайдер не ответил: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
provider.tokenHits++
|
||||
if provider.failToken {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
write(w, `{"error":"invalid_grant"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
|
||||
})
|
||||
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
|
||||
})
|
||||
|
||||
provider.server = httptest.NewServer(mux)
|
||||
t.Cleanup(provider.server.Close)
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
// loginEnv — окружение проверки входа: хранилище с настроенным подставным
|
||||
// провайдером и собранный роутер со всеми слоями.
|
||||
type loginEnv struct {
|
||||
app core.App
|
||||
mux http.Handler
|
||||
handler *AuthHandler
|
||||
provider *fakeProvider
|
||||
}
|
||||
|
||||
func setupLoginEnv(t *testing.T) *loginEnv {
|
||||
t.Helper()
|
||||
|
||||
app := newTestStorage(t)
|
||||
provider := newFakeProvider(t)
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: provider.server.URL + "/authorize",
|
||||
TokenURL: provider.server.URL + "/token",
|
||||
UserInfoURL: provider.server.URL + "/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "local-test-secret",
|
||||
}))
|
||||
|
||||
handler := NewAuthHandler(app, AuthHandlerConfig{
|
||||
AuthURL: provider.server.URL + "/authorize",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
|
||||
}
|
||||
|
||||
// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
|
||||
// выданным состоянием — тем, что сервис ждёт обратно.
|
||||
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == stateCookieName {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, cookie, "носитель состояния не поставлен")
|
||||
|
||||
location, err := url.Parse(w.Result().Header.Get("Location"))
|
||||
require.NoError(t, err)
|
||||
state = location.Query().Get("state")
|
||||
require.NotEmpty(t, state)
|
||||
|
||||
return cookie, state
|
||||
}
|
||||
|
||||
// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
|
||||
// провайдера, и он получает сессию.
|
||||
//
|
||||
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
|
||||
// работающим: прочие проверки заводят запись мимо входа.
|
||||
func TestLoginCreatesAccountAndSession(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
before, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
|
||||
assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")
|
||||
|
||||
after, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")
|
||||
|
||||
var session *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookieName {
|
||||
session = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, session, "кука сессии не поставлена")
|
||||
|
||||
// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
|
||||
// уносит её по незашифрованному соединению.
|
||||
assert.True(t, session.HttpOnly)
|
||||
assert.True(t, session.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
|
||||
assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
|
||||
assert.NotEmpty(t, session.Value)
|
||||
|
||||
// Носитель состояния убран — и убран так, что это видно готовому ответу, а
|
||||
// не только живой карте заголовков.
|
||||
var cleared bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == stateCookieName && c.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
assert.True(t, cleared, "носитель состояния пережил возврат")
|
||||
|
||||
// Выданная сессия открывает доступ к закрытым адресам.
|
||||
check := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
|
||||
check.AddCookie(session)
|
||||
checkResponse := httptest.NewRecorder()
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
NewAppHandler(
|
||||
pbrepo.NewAudioRecordRepository(env.app),
|
||||
pbrepo.NewTextRepository(env.app),
|
||||
pbrepo.NewStructureRepository(env.app),
|
||||
nil, nil,
|
||||
).Register(r)
|
||||
checkMux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
checkMux.ServeHTTP(checkResponse, check)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, checkResponse.Code,
|
||||
"сессия не открыла доступ: получен %d", checkResponse.Code)
|
||||
}
|
||||
|
||||
// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
|
||||
// пускает постороннего.
|
||||
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
||||
"посторонний завёл себе учётную запись: %s", w.Body.String())
|
||||
|
||||
accounts, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, accounts)
|
||||
}
|
||||
|
||||
// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
|
||||
//
|
||||
// Сборка роутера хранилища вешает обработчики на само приложение и без
|
||||
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
|
||||
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
|
||||
// что обмен исполняется раньше обращения к провайдеру.
|
||||
func TestCallbackDoesNotLeakHooks(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
count := func() int {
|
||||
hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
|
||||
return hook.Len()
|
||||
}
|
||||
|
||||
// Первый вход собирает роутер — с него и считаем.
|
||||
cookie, state := env.startLogin(t)
|
||||
first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
|
||||
first.AddCookie(cookie)
|
||||
env.mux.ServeHTTP(httptest.NewRecorder(), first)
|
||||
|
||||
before := count()
|
||||
|
||||
for range 20 {
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
env.mux.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
assert.Equal(t, before, count(),
|
||||
"обработчики копятся: 20 входов добавили %d", count()-before)
|
||||
}
|
||||
|
||||
// TestSessionRefreshIsClosed: сессия не продлевает саму себя.
|
||||
//
|
||||
// При живом продлении срок её жизни ничего не значит, а вместе с ним перестаёт
|
||||
// работать единственный канал, которым отзыв доступа у провайдера доходит до
|
||||
// сервиса.
|
||||
func TestSessionRefreshIsClosed(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
var session *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookieName {
|
||||
session = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, session)
|
||||
|
||||
refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
|
||||
refresh.Header.Set("Authorization", session.Value)
|
||||
refreshResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(refreshResponse, refresh)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
|
||||
"сессия продлилась: %s", refreshResponse.Body.String())
|
||||
|
||||
// И нового значения в ответе нет — продлевать нечем.
|
||||
assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
|
||||
}
|
||||
|
||||
// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
|
||||
func TestCallbackRejectsProviderFailure(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
env.provider.failToken = true
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
for _, c := range w.Result().Cookies() {
|
||||
assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
|
||||
}
|
||||
|
||||
accounts, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, accounts)
|
||||
}
|
||||
|
||||
// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
|
||||
// отдаётся анониму.
|
||||
func TestRecordFileNeedsSessionAndToken(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
names := files[0].GetStringSlice("file")
|
||||
require.Len(t, names, 1)
|
||||
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
|
||||
|
||||
// Аноним не проходит.
|
||||
anonymous := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
|
||||
assert.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
|
||||
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||
|
||||
// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
|
||||
// защищённый файл судится этим токеном, а не сессионной кукой.
|
||||
tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
|
||||
tokenRequest.Header.Set("Authorization", env.session)
|
||||
tokenResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(tokenResponse, tokenRequest)
|
||||
require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())
|
||||
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
|
||||
require.NotEmpty(t, payload.Token)
|
||||
|
||||
withToken := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, withToken.Code,
|
||||
"вошедший не получил файл: %d", withToken.Code)
|
||||
assert.Contains(t, withToken.Body.String(), "audio content")
|
||||
}
|
||||
@@ -17,40 +17,30 @@ import (
|
||||
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
|
||||
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
|
||||
|
||||
// newSecondAccount заводит вторую учётную запись с собственной сессией.
|
||||
// Постоянный адрес почты первой занят, и повторное сохранение отвергается —
|
||||
// адрес здесь свой.
|
||||
// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный
|
||||
// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь
|
||||
// свой.
|
||||
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
t.Helper()
|
||||
|
||||
const login = "stranger"
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "stranger@example.com")
|
||||
record.Set("verified", true)
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
token, err := record.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
return record, token
|
||||
return record, login
|
||||
}
|
||||
|
||||
// withSessionHeader предъявляет сессию заголовком. Собственная поверхность
|
||||
// хранилища читается только так: слой, перекладывающий куку в заголовок, на неё
|
||||
// намеренно не наведён — часть её защищена ровно тем, что браузер заголовка сам
|
||||
// не шлёт.
|
||||
func withSessionHeader(req *http.Request, session string) *http.Request {
|
||||
req.Header.Set("Authorization", session)
|
||||
return req
|
||||
}
|
||||
|
||||
// serveAs шлёт запрос от имени названной сессии, а не сессии окружения.
|
||||
func serveAs(env *testEnv, session string, w http.ResponseWriter, req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session})
|
||||
env.mux.ServeHTTP(w, req)
|
||||
// serveAs шлёт запрос от имени названного логина, а не логина окружения.
|
||||
func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) {
|
||||
env.mux.ServeHTTP(w, asUser(req, login))
|
||||
}
|
||||
|
||||
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
|
||||
@@ -139,8 +129,13 @@ func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
|
||||
token, err := admin.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему
|
||||
// никто не ставит, и узнавание по нему его учётной записи не касается.
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
serveAs(env, token, w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
|
||||
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
|
||||
@@ -164,13 +159,15 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
||||
|
||||
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
|
||||
|
||||
// Переход по ссылке идёт **без** заголовка: файл судится своим коротким
|
||||
// токеном, а не узнаванием. Так же по ней пойдёт и браузер.
|
||||
mine := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(mine, withSessionHeader(
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.session), http.NoBody), env.session))
|
||||
env.mux.ServeHTTP(mine,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody))
|
||||
|
||||
foreign := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(foreign, withSessionHeader(
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody), stranger))
|
||||
env.mux.ServeHTTP(foreign,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
|
||||
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
|
||||
@@ -179,15 +176,19 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
||||
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
|
||||
}
|
||||
|
||||
// fileToken берёт у хранилища токен файла для названной сессии. Токен выдаётся
|
||||
// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся
|
||||
// на предъявителя: о файле хранилище при выдаче не спрашивает.
|
||||
func fileToken(t *testing.T, env *testEnv, session string) string {
|
||||
//
|
||||
// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на
|
||||
// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка»
|
||||
// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера.
|
||||
func fileToken(t *testing.T, env *testEnv, login string) string {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, withSessionHeader(
|
||||
httptest.NewRequest("POST", "/api/files/token", http.NoBody), session))
|
||||
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому вошедшему")
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest("POST", "/api/files/token", http.NoBody), login))
|
||||
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному")
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
|
||||
@@ -56,3 +56,37 @@ func ApplyAppRateLimit(app core.App) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать
|
||||
// адрес спрашивающего.
|
||||
//
|
||||
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с
|
||||
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет
|
||||
// в этом случае общий на весь сервис: восемь одновременно открытых карточек
|
||||
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного
|
||||
// запроса. Норма при этом требует обратного — бюджет считается по адресу
|
||||
// спрашивающего.
|
||||
//
|
||||
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес
|
||||
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно
|
||||
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру,
|
||||
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**,
|
||||
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ
|
||||
// счётчика сам и обходит ограничитель, меняя значение.
|
||||
//
|
||||
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл,
|
||||
// по-прежнему решает адрес самого соединения.
|
||||
func ApplyTrustedProxyHeaders(app core.App) error {
|
||||
settings := app.Settings()
|
||||
|
||||
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"}
|
||||
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда
|
||||
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий,
|
||||
// чем один пустой ключ на всех.
|
||||
settings.TrustedProxy.UseLeftmostIP = true
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
)
|
||||
|
||||
// RefreshPath — адрес хранилища, которым сессия продлевает саму себя.
|
||||
const RefreshPath = "/api/collections/users/auth-refresh"
|
||||
|
||||
// BlockSessionRefresh закрывает продление сессии.
|
||||
//
|
||||
// Хранилище выдаёт сессию продлеваемой: предъявитель значения меняет его на
|
||||
// новое, с новым сроком, и делает это сколько угодно раз, никуда не входя. При
|
||||
// живом продлении срок жизни сессии перестаёт что-либо значить, а вместе с ним
|
||||
// перестаёт работать единственный канал, которым отзыв доступа у провайдера
|
||||
// доходит до сервиса, — сервис после входа к провайдеру не обращается.
|
||||
//
|
||||
// Решение владельца от 2026-08-12: продление выключено, цена — вход раз в
|
||||
// семь суток.
|
||||
func BlockSessionRefresh() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberBlockSessionRefresh",
|
||||
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 2,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Request.URL.Path == RefreshPath {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "Продление сессии выключено",
|
||||
})
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SessionFromCookie перекладывает значение куки сессии в заголовок, которым
|
||||
// хранилище читает предъявленную сессию.
|
||||
//
|
||||
// Куки хранилище не читает вовсе — только заголовок `Authorization`. Браузер же
|
||||
// сам заголовка не шлёт, а своей страницы со скриптом у сервиса нет, поэтому
|
||||
// сессия предъявляется кукой, а способ проверки остаётся один.
|
||||
//
|
||||
// Предъявленный заголовок побеждает: иначе браузер с сессионной кукой получал
|
||||
// бы на собственных адресах хранилища не то, что предъявил.
|
||||
//
|
||||
// Слой стоит раньше проверки токена: тот идёт с приоритетом
|
||||
// DefaultLoadAuthTokenMiddlewarePriority и к этому моменту заголовок должен
|
||||
// быть на месте.
|
||||
func SessionFromCookie() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberSessionFromCookie",
|
||||
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 1,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Request.Header.Get("Authorization") != "" {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
cookie, err := e.Request.Cookie(SessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
e.Request.Header.Set("Authorization", cookie.Value)
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -261,12 +261,13 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil))
|
||||
handler.Register(r)
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
asUser(req, env.login)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -72,50 +73,70 @@ type testEnv struct {
|
||||
handler *AppHandler
|
||||
app core.App
|
||||
journal *journalBuffer
|
||||
// session — значение сессии вошедшего. Приём и опрос закрыты за
|
||||
// аутентификацией, и проверка, судящая их по существу, обязана предъявить
|
||||
// сессию ровно так же, как это делает браузер.
|
||||
session string
|
||||
// account — учётная запись, которой выдана сессия. Нужна проверкам выхода.
|
||||
// login — логин у провайдера, которым доверенный источник называет
|
||||
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
|
||||
// по существу, обязана назваться ровно так же, как это делает прокси.
|
||||
login string
|
||||
// account — учётная запись, которой принадлежит этот логин.
|
||||
account *core.Record
|
||||
}
|
||||
|
||||
// serve шлёт запрос от имени вошедшего: сессия предъявляется кукой — тем же
|
||||
// способом, каким её предъявляет браузер. Заголовок проверки не ставят: куку в
|
||||
// него перекладывает слой предъявления, и подмена его здесь означала бы проверку
|
||||
// не той цепочки.
|
||||
// Доверенный источник проверок.
|
||||
//
|
||||
// Проверки, судящие отказ без сессии, зовут `mux` напрямую.
|
||||
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
|
||||
e.mux.ServeHTTP(w, req)
|
||||
// Подсеть и адрес пира разведены намеренно: проверка недоверенного источника
|
||||
// берёт адрес **вне** подсети, и обе стороны условия видны рядом.
|
||||
const (
|
||||
trustedPeer = "10.1.2.3:34567"
|
||||
untrustedPeer = "203.0.113.9:34567"
|
||||
)
|
||||
|
||||
func testTrustedNetworks(t *testing.T) []netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
prefix, err := netip.ParsePrefix("10.0.0.0/8")
|
||||
require.NoError(t, err)
|
||||
|
||||
return []netip.Prefix{prefix}
|
||||
}
|
||||
|
||||
// newTestAccount заводит учётную запись и выдаёт ей сессию.
|
||||
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
|
||||
// пира из доверенной подсети — ровно то, что делает обратный прокси.
|
||||
func asUser(req *http.Request, login string) *http.Request {
|
||||
req.Header.Set(LoginHeader, login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
return req
|
||||
}
|
||||
|
||||
// serve шлёт запрос от имени узнанного. Проверки, судящие отказ неузнанному,
|
||||
// зовут `mux` напрямую: запрос без заголовка и есть запрос неузнанного.
|
||||
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
||||
e.mux.ServeHTTP(w, asUser(req, e.login))
|
||||
}
|
||||
|
||||
// newTestAccount заводит учётную запись с известным логином.
|
||||
//
|
||||
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
|
||||
// запросом больше нельзя — создание закрыто шагом схемы, и в этом весь смысл
|
||||
// изменения. Прямое сохранение идёт мимо правил доступа так же, как идёт вход,
|
||||
// когда учётную запись заводит само хранилище.
|
||||
// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо
|
||||
// правил доступа так же, как идёт заведение записи первым обращением.
|
||||
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
t.Helper()
|
||||
|
||||
const login = "person"
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "person@example.com")
|
||||
record.Set("verified", true)
|
||||
// Случайный пароль ставит и само хранилище, когда заводит запись по входу у
|
||||
// провайдера: запись auth-коллекции без пароля не сохраняется, а войти по
|
||||
// Случайный пароль ставит и сам сервис, когда заводит запись первым
|
||||
// обращением: запись auth-коллекции без пароля не сохраняется, а войти по
|
||||
// нему всё равно нельзя — парольный вход выключен шагом схемы.
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
token, err := record.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
return record, token
|
||||
return record, login
|
||||
}
|
||||
|
||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||
@@ -191,23 +212,27 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
|
||||
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
|
||||
|
||||
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
|
||||
// обработчик, и проверка судит ту же цепочку, что и прод.
|
||||
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается
|
||||
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку,
|
||||
// что и прод. Собери роутер иначе — и проверка судила бы не то.
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
mounts := ServiceMounts(handler, http.NotFoundHandler())
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
account, session := newTestAccount(t, app)
|
||||
account, login := newTestAccount(t, app)
|
||||
|
||||
return &testEnv{
|
||||
mux: mux,
|
||||
handler: handler,
|
||||
app: app,
|
||||
journal: journal,
|
||||
session: session,
|
||||
login: login,
|
||||
account: account,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,16 @@ import (
|
||||
//
|
||||
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
|
||||
// панели владельца. Поменять их нельзя, это литералы библиотеки.
|
||||
//
|
||||
// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и
|
||||
// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают
|
||||
// тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
|
||||
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
|
||||
// стоит, ничем не отличается от любого другого свободного, а второй перечень
|
||||
// «когда-то занятых корней» разошёлся бы с первым молча.
|
||||
const (
|
||||
StorageRoot = "/api"
|
||||
PanelRoot = "/_"
|
||||
AuthRoot = "/auth"
|
||||
|
||||
HealthPath = "/health"
|
||||
MetricsPath = "/metrics"
|
||||
@@ -74,16 +80,11 @@ func (m Mount) Covers(requestPath string) bool {
|
||||
}
|
||||
|
||||
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
||||
func ServiceMounts(
|
||||
appHandler *AppHandler,
|
||||
authHandler *AuthHandler,
|
||||
metricsHandler http.Handler,
|
||||
) []Mount {
|
||||
func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount {
|
||||
return []Mount{
|
||||
{Path: StorageRoot},
|
||||
{Path: PanelRoot},
|
||||
{Path: AppRoot, Bind: appHandler.Register},
|
||||
{Path: AuthRoot, Bind: authHandler.Register},
|
||||
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
||||
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func builtDist() fs.FS {
|
||||
type webappEnv struct {
|
||||
mux http.Handler
|
||||
journal *journalBuffer
|
||||
session string
|
||||
login string
|
||||
}
|
||||
|
||||
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||
@@ -68,36 +68,32 @@ func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||
)
|
||||
|
||||
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
|
||||
authHandler := NewAuthHandler(app, AuthHandlerConfig{
|
||||
AuthURL: "https://provider.example/authorize",
|
||||
RedirectURL: "https://service.example/auth/callback",
|
||||
ClientID: "client",
|
||||
}, logger)
|
||||
|
||||
mounts := ServiceMounts(appHandler, authHandler, http.NotFoundHandler())
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
RegisterServiceRoutes(r, mounts)
|
||||
NewWebappHandler(dist, built, mounts, logger).Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, session := newTestAccount(t, app)
|
||||
_, login := newTestAccount(t, app)
|
||||
|
||||
return &webappEnv{mux: mux, journal: journal, session: session}
|
||||
return &webappEnv{mux: mux, journal: journal, login: login}
|
||||
}
|
||||
|
||||
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||
return e.do(http.MethodGet, path, false)
|
||||
}
|
||||
|
||||
func (e *webappEnv) do(method, path string, withSession bool) *httptest.ResponseRecorder {
|
||||
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if withSession {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
|
||||
if identified {
|
||||
asUser(req, e.login)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -124,7 +120,7 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
for _, path := range []string{"/api/nope", "/_/nope", "/auth/nope"} {
|
||||
for _, path := range []string{"/api/nope", "/_/nope"} {
|
||||
res := env.get(path)
|
||||
|
||||
assert.NotContains(t, res.Body.String(), "приложение", path)
|
||||
|
||||
@@ -716,6 +716,9 @@ func newOwner(t *testing.T, app core.App) string {
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
|
||||
// пустым ключом схема не примет.
|
||||
record.Set(migrations.ProviderLoginField, uuid.NewString())
|
||||
record.Set("email", uuid.NewString()+"@example.test")
|
||||
record.Set("verified", true)
|
||||
record.Set("password", uuid.NewString())
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-22
|
||||
@@ -0,0 +1,361 @@
|
||||
## Context
|
||||
|
||||
Сегодня сервис проводит вход сам: адрес `/auth/login` уводит человека к
|
||||
Authelia, положив состояние и проверочный код PKCE в куку `transcriber_login`;
|
||||
`/auth/callback` сверяет состояние и меняет принесённый код на сессию
|
||||
внутрипроцессным запросом к собственному адресу хранилища; сессия уезжает кукой
|
||||
`transcriber_session` и живёт семь суток. Секрет клиента ради этого обмена лежит
|
||||
в конфиге и приводится к настройкам коллекции пользователей при каждом подъёме.
|
||||
|
||||
Обратный прокси контура (`files/caddyproxy/Caddyfile.template` в
|
||||
`pet-project-server`) уже спрашивает Authelia через `forward_auth` и копирует
|
||||
ответ в заголовки `Remote-User`, `Remote-Groups`, `Remote-Email`, `Remote-Name`
|
||||
трём соседним сервисам. Правила для этого сервиса там нет: он не выложен.
|
||||
Контейнер портов наружу не публикует.
|
||||
|
||||
Проект на стройке: данных на сервере нет, совместимость не требуется. Применённый
|
||||
шаг схемы по-прежнему не переписывается.
|
||||
|
||||
**Что измерено пробником** на временной базе (прогон 2026-08-22, программа
|
||||
удалена):
|
||||
|
||||
- коллекция `users` требует непустых `email` и `password`; поле `email`
|
||||
переводится в необязательное шагом схемы, `password` — нет ни при каком
|
||||
значении признака;
|
||||
- уникальность почты держится **частичным** индексом (`WHERE email != ''`),
|
||||
поэтому запись без почты законна и второй такой же не мешает;
|
||||
- своя колонка с уникальным индексом отвергает вторую запись с тем же значением
|
||||
и находится поиском по значению;
|
||||
- запись, заведённая так, выдаёт файловый токен — то есть годится как учётная
|
||||
запись предъявителя во всём, что делает сервис.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Узнавать пришедшего заголовком доверенного источника и никак иначе.
|
||||
- Убрать собственный протокол входа целиком, вместе с куками, состоянием, PKCE,
|
||||
обменом кода и выходом.
|
||||
- Убрать секрет клиента из конфига и из базы.
|
||||
- Оставить рабочим путь к файлу записи: предъявиться, взять у хранилища короткий
|
||||
файловый токен, пройти по ссылке.
|
||||
- Оставить рабочим вход владельца в панель его собственным паролем.
|
||||
- Дать способ представиться на машине без прокси.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Второй уровень доступа по `Remote-Groups`: его потребителя — страницы расхода
|
||||
— ещё нет.
|
||||
- Личные токены для программ: их заводит `api-tokens`.
|
||||
- Правило обратного прокси и правило Authelia для домена сервиса: они живут в
|
||||
`pet-project-server`, и эта работа их не пишет и не проверяет.
|
||||
- Приведение пути к канонической форме (обход панели подменой знака): барьер
|
||||
переезжает на домен, и правило на литерал пути перестаёт быть единственным, но
|
||||
сама канонизация — не предмет этой работы.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Р1. Доверие определяется адресом пира соединения
|
||||
|
||||
Заголовку верят, когда запрос пришёл с адреса из объявленного перечня. Адрес
|
||||
берётся у самого соединения (`RemoteAddr`), а не из пересылаемых заголовков.
|
||||
|
||||
Отвергнуто:
|
||||
|
||||
- **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
|
||||
запрос. Барьер, который подделывается той же строкой, что и обходится, не
|
||||
барьер вовсе.
|
||||
- **Готовое разрешение адреса у библиотеки** (`RealIP`). Оно читает
|
||||
пересылаемый заголовок, когда настроен список доверенных прокси, а сам список
|
||||
живёт в настройках хранилища — то есть в базе, а не в конфиге. Условие доверия
|
||||
оказалось бы в месте, которое правят руками в панели.
|
||||
- **«Контейнер портов не публикует, значит доверяем всякому».** Условие верное,
|
||||
но непроверяемое отсюда: правка раскладки контейнера открыла бы сервис молча,
|
||||
и оракула у второго критерия приёмки не было бы вовсе.
|
||||
|
||||
### Р2. Слой действует на объявленной области, а не на всём роутере
|
||||
|
||||
Слой вешается корневым — иначе к нужным адресам его не привязать, — но узнаёт
|
||||
пришедшего **только на объявленной области**: корень приложения плюс адрес,
|
||||
которым хранилище выдаёт короткий файловый токен. Область берётся из перечня
|
||||
адресного пространства, а не пишется вторым списком; адрес файлового токена
|
||||
добавляется к нему поимённо. Устроено это ровно так же, как сегодня устроен
|
||||
запрет продления сессии: корневая привязка и проверка пути внутри.
|
||||
|
||||
Учётная запись ставится **только когда её ещё нет** — то есть после того, как
|
||||
библиотека прочитала предъявленный токен и никого не нашла. Предъявленным
|
||||
считается токен, который библиотека **прочитала успешно**: протухший и негодный
|
||||
предъявленными не считаются, и такой запрос узнаётся заголовком.
|
||||
|
||||
**Первая редакция вешала узнавание на весь роутер, и это была дыра.** Прежняя
|
||||
спека держала обратное требование — «область действия слоя MUST быть ограничена
|
||||
адресами приложения… часть поверхности хранилища защищена сегодня ровно тем, что
|
||||
браузер заголовка сам не шлёт, и расширение слоя на всё сняло бы эту защиту
|
||||
молча», — а первая редакция снимала это требование вместе с кукой и замены ему не
|
||||
давала. Сложение с Р5 (ключ учётной записи переезжает в обычную колонку
|
||||
пользователей) давало путь захвата: узнанный человек шлёт
|
||||
`PATCH /api/collections/users/records/{свой id}` и ставит себе ключом чужое имя,
|
||||
а первое обращение настоящего владельца этого имени попадает в **его** учётную
|
||||
запись — вместе со всем архивом. Правило правки у коллекции пользователей
|
||||
библиотечное и разрешает править свою запись; **проверено пробником**: у
|
||||
коллекции `users` `ListRule`, `ViewRule`, `UpdateRule` и `DeleteRule` равны
|
||||
`id = @request.auth.id`, а применённый шаг `202608120001_oidc_login` трогает
|
||||
только правило создания.
|
||||
|
||||
Второе следствие сужения: узнавание больше не срабатывает на пробе здоровья, на
|
||||
метриках и на каждом ресурсе сборщика. Иначе всякий запрос за картинкой стоил бы
|
||||
обращения к базе, а первый такой запрос с новым именем — записи в неё.
|
||||
|
||||
Отвергнуто: **вешать под корнем приложения и завести отдельный путь к файлу**.
|
||||
Второй способ добраться до файла — второй дом одного правила, и разошлись бы они
|
||||
на первой же правке разграничения.
|
||||
|
||||
### Р2а. Поверхность коллекции пользователей закрывается схемой
|
||||
|
||||
Новый шаг схемы снимает у коллекции пользователей все правила доступа —
|
||||
перечисление, чтение, создание, правку и удаление, — оставляя их пустыми, то есть
|
||||
«только владелец панели». Наш код читает и заводит запись мимо правил, панель
|
||||
работает суперпользователем, своих экранов профиля сервис не заводит.
|
||||
|
||||
Это второй барьер поверх Р2, и он нужен именно вторым. Прежняя спека сама
|
||||
называла защиту, державшуюся на том, что браузер не шлёт заголовка,
|
||||
**случайной** — и была права; область слоя это та же случайность, только с
|
||||
другой стороны. Сверку берёт на себя проверка схемы: коллекция пользователей
|
||||
дописывается к шести, которые она уже сторожит, — сегодня она единственная из
|
||||
семи проверяется только умолчаниями библиотеки.
|
||||
|
||||
Отсюда же норма, которой в первой редакции не было вовсе: **ключ учётной записи
|
||||
не правится ничем, кроме заведения самим сервисом.** Правило доступа закрывает
|
||||
путь снаружи, норма закрывает и правку рукой в панели: переписанный ключ отдаёт
|
||||
архив следующему, кто придёт с этим именем, и вернуть его будет нечем.
|
||||
|
||||
### Р3. Сессия не выдаётся никакая
|
||||
|
||||
Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
|
||||
заголовку, который прокси поставил, сходив к Authelia.
|
||||
|
||||
Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
|
||||
значение, живущее семь суток, отозванный у провайдера человек работал до
|
||||
истечения этого значения, и другого канала отзыва не было.
|
||||
|
||||
Отвергнуто: **выдавать сессию хранилища по первому запросу с заголовком** — куку
|
||||
ставить, дальше верить ей. Дешевле в работе (слой срабатывал бы раз в неделю, а
|
||||
не на каждом запросе), но возвращает ровно то, что задача убирает: значение,
|
||||
переживающее отзыв. Семь суток вернулись бы вместе с ним.
|
||||
|
||||
Цена принятого решения: поиск учётной записи по значению заголовка идёт на
|
||||
**каждом** запросе к сервису. Уникальный индекс делает это одним обращением к
|
||||
базе; сервисом пользуются единицы человек, и замера здесь не требуется.
|
||||
|
||||
### Р4. Имена заголовков нормативны, настраивается перечень доверенных адресов
|
||||
|
||||
Имена `Remote-User`, `Remote-Name`, `Remote-Email` записываются в спеку и
|
||||
живут константами — как жило имя куки сессии, и по той же причине: смена имени
|
||||
молча перестаёт узнавать всех, а тест, который сам ставит и сам читает своё имя,
|
||||
этого не замечает. Контур уже пишет эти имена трём соседним сервисам.
|
||||
|
||||
Настройкой приходит одно — перечень адресов, чьему заголовку верят. Он
|
||||
непустой обязателен: пустой перечень значит «не верить никому», то есть сервис,
|
||||
поднявшийся никого не узнающим, и молчать об этом старт не вправе.
|
||||
|
||||
Отвергнуто: **имена заголовков ключами конфига**. Второе место, где их можно
|
||||
написать неверно, а выгоды нет: контур один и пишет одно.
|
||||
|
||||
Имена ключей конфига — необратимое. **Решено человеком на чекпоинте
|
||||
2026-08-22:** секция `[auth]` остаётся под своим именем, в ней один ключ —
|
||||
`trusted_proxies`. Довод: секция сохраняет смысл «кому мы верим на входе», а
|
||||
проверка настроек на старте остаётся там же, где была.
|
||||
|
||||
### Р5. Ключ учётной записи — своя колонка
|
||||
|
||||
Значение `Remote-User` ложится в свою колонку коллекции пользователей,
|
||||
уникальную. По ней запись ищется и по ней же заводится, если её ещё нет.
|
||||
|
||||
Отвергнуто:
|
||||
|
||||
- **Адрес почты ключом.** Authelia не обязана его отдавать, человек его меняет,
|
||||
а первый вход с чужим адресом достался бы чужой записи — ровно тот захват,
|
||||
ради которого прежний шаг схемы закрывал создание записи.
|
||||
- **Таблица внешних учётных записей библиотеки.** Она принадлежит механике
|
||||
OAuth2, которую эта работа убирает целиком.
|
||||
- **Не брать `Remote-Email` вовсе.** Довод за: поля с личными сведениями,
|
||||
у которого нет ни одного потребителя, в периметре быть не должно — ключом
|
||||
почта не служит, в журнал и в ответ ей ходу нет, а ради неё шаг схемы ещё и
|
||||
делает колонку необязательной. Отвергнуто потому, что потребитель назван и
|
||||
ждёт своей очереди: задача `email-notification` шлёт готовый текст на адрес из
|
||||
учётной записи, и строка про это стоит в модели угроз, раздел «Куда уходит
|
||||
содержимое записи». Взять адрес заголовком в тот день будет нечем — заголовок
|
||||
приходит с запросом человека, а рассылка идёт из конвейера.
|
||||
|
||||
### Р5а. Цена ключа названа в обе стороны
|
||||
|
||||
Переименование у провайдера заводит новую учётную запись — это первая половина, и
|
||||
она была названа сразу. Вторая: **логин переиспользуем**. Человек, которому
|
||||
выдали логин ушедшего, при первом же обращении попадает в **существующую** запись
|
||||
и получает весь её архив — голосовые записи семьи, расшифровки, тексты, то есть
|
||||
самое чувствительное, что у сервиса есть.
|
||||
|
||||
Записывается это в спеку и повторяется в модели угроз, а не чинится: неизменяемого
|
||||
признака заголовок не приносит, и не допускать переиспользования логинов — работа
|
||||
провайдера, а не сервиса. Дефект был бы в умолчании — в том, что риск не назван, и
|
||||
следующий читатель счёл бы вопрос закрытым абзацем про переименование.
|
||||
|
||||
Третья сторона той же цены: у осиротевшей записи прежнего человека остаётся архив,
|
||||
который нечем ни слить с новой, ни убрать — владелец записи назначается один раз и
|
||||
не меняется, а учётная запись с записями не удаляется по норме `storage`.
|
||||
|
||||
Имя колонки — `provider_login`, и оно говорит о происхождении значения: это логин
|
||||
человека **у провайдера**, а не наш идентификатор. Имя уезжает шагом схемы и
|
||||
потому не переписывается.
|
||||
|
||||
Заведение записи: имя — из `Remote-Name`, почта — из `Remote-Email`, если тот
|
||||
пришёл. Почта перестаёт быть обязательной (шаг схемы), пароль записи
|
||||
обязателен всегда — ей ставится случайный, употребить его нельзя, потому что
|
||||
вход по паролю у коллекции выключен.
|
||||
|
||||
Найденную запись повторное обращение **не переписывает**: имя и почта берутся
|
||||
только при заведении. Иначе каждый запрос был бы записью в базу, а правка имени
|
||||
в Authelia переписывала бы карточку человека молча, посреди его работы.
|
||||
|
||||
**Значение принимается, а не берётся как есть.** Пустое и состоящее из пробельных
|
||||
знаков не узнаёт никого и записи не заводит: прокси штатно шлёт пустой заголовок
|
||||
там, где никого не назвал, и по букве «первое обращение с новым значением» все
|
||||
неназванные собрались бы в одну общую учётную запись с общим архивом. По той же
|
||||
причине отвергается запрос, несущий **более одного** значения `Remote-User`:
|
||||
прокси, настроенный добавлять заголовок вместо замены, оставляет присланный
|
||||
анонимом рядом со своим, и умолчание «берём первый» отдаёт вход анониму. Сверх
|
||||
того — предел длины и отказ на управляющие знаки, а в хранилище значение уходит
|
||||
параметром, а не подстановкой в текст фильтра. Класс у проекта уже был: хвост
|
||||
имени отправителя, уехавший меткой метрики, чинился приведением на входе, а не
|
||||
запретом на выходе.
|
||||
|
||||
Сравнение при поиске — точное, знак в знак. Приведение регистра завело бы правило,
|
||||
которого нет у провайдера: считает ли Authelia `admin` и `Admin` одним человеком,
|
||||
сервису неизвестно, а угаданное правило склеило бы двух разных людей молча.
|
||||
|
||||
**Два отказа уникальности различаются.** По ключевой колонке — гонка двух первых
|
||||
обращений одним именем: код повторяет поиск и продолжает. По любой другой —
|
||||
почта, пришедшая от провайдера, уже занята другой учётной записью (общий
|
||||
почтовый ящик, семья, группа) — запись заводится **без почты**, потому что почта
|
||||
необязательна, и остаётся строка журнала с идентификатором записи. Без этого
|
||||
разреза второй человек с той же почтой не завёлся бы никогда: повторный поиск по
|
||||
имени снова ничего не нашёл бы, и исход выродился бы либо в цикл, либо в вечный
|
||||
отказ без внятной причины.
|
||||
|
||||
**Отказ хранилища при узнавании — отказ сервиса, а не «вас не узнали».** Молчаливый
|
||||
проход неузнанным показал бы человеку отказ входа там, где легла база.
|
||||
|
||||
### Р6. Заголовок с недоверенного адреса просто не действует
|
||||
|
||||
Слой при этом отказа не выдаёт. Отказ приходит там, где приходил и раньше, —
|
||||
требованием учётной записи под корнем приложения, кодом `401`.
|
||||
|
||||
Отвергнуто: **отвечать отказом прямо в слое**. Проба здоровья, метрики и
|
||||
разметка приложения открыты анонимно по спеке; отказ в слое закрыл бы их
|
||||
всякому, кто пришлёт заголовок, — то есть чужая строка в запросе гасила бы
|
||||
наблюдение за сервисом.
|
||||
|
||||
### Р7. Значение заголовка в журнал не идёт
|
||||
|
||||
Пишется факт и исход — «заголовок пришёл с недоверенного адреса», «учётная
|
||||
запись заведена», — с идентификатором записи, но без значения. Довод тот же,
|
||||
каким причина отказа провайдера приводилась к перечню известных: значением
|
||||
целиком распоряжается тот, кто шлёт запрос, а с недоверенного адреса это ещё и
|
||||
аноним. Плюс само имя принадлежит человеку — наравне с адресом почты, которому
|
||||
спека уже запрещает попадать в журнал.
|
||||
|
||||
### Р7а. У правила «найти или завести человека» есть дом, и он не в транспорте
|
||||
|
||||
Правило — поиск по ключу, заведение при отсутствии, приём значения, разрез двух
|
||||
отказов уникальности, «найденную не переписывать» — живёт **методом пакета
|
||||
хранилища**, а слой транспорта только читает заголовок, судит адрес пира и зовёт
|
||||
метод. Дом объявляется строкой в перечне единых точек проекта.
|
||||
|
||||
Причина в следующей задаче, а не в чистоте. `api-tokens` заводит **второй** способ
|
||||
представиться и в своей записи прямо говорит: «два способа представиться и один
|
||||
владелец». Правило, уложенное куском в слой транспорта, придётся тогда либо
|
||||
продублировать вторым куском — и он разойдётся с первым на первой же правке, —
|
||||
либо вытаскивать задним числом. Стоимость следующего изменения здесь считается:
|
||||
при вынесенном правиле новый способ представиться стоит одного нового слоя.
|
||||
|
||||
Отвергнуто: **контракт в `internal/contract` с реализацией в адаптере**. Дороже на
|
||||
интерфейс, у которого сегодня одна реализация и второго потребителя вне HTTP не
|
||||
предвидится; правила направления зависимостей транспорту знать адаптер хранилища
|
||||
разрешают.
|
||||
|
||||
### Р8. Способ представиться без прокси — развилка
|
||||
|
||||
Подставной провайдер уходит вместе с протоколом, и его место пусто. На это место
|
||||
опирается `dev-run-task`, которая обещает адрес, по которому открывают
|
||||
приложение. Варианты:
|
||||
|
||||
| Способ | Что даёт | Цена |
|
||||
| --- | --- | --- |
|
||||
| **Один main-пакет оснастки с подкомандами** — `cmd/devtools proxy` ставит заголовок и переправляет запрос сервису, `cmd/devtools admin` заводит владельца панели | Приложение открывается браузером обычным образом; сервис остаётся без единой ветки для разработчика; строка исключения в сборке образа остаётся одна навсегда | Соседняя задача `dev-run-task` планировала свой пакет `cmd/devadmin` — её придётся переформулировать под подкоманду |
|
||||
| **Отдельный main-пакет на каждый инструмент** — как было с подставным провайдером | Ничего не надо согласовывать с соседней задачей | Каждый инструмент стоит четырёх мест: строка сборки образа, «Деплой» в устройстве, «Команды» в памятке, `README`. Забытая строка сборки тихо кладёт инструмент разработчика в боевой образ, а инструментов сразу становится два |
|
||||
| **Контейнер с прокси вместо своего кода** — контур и так стоит на Caddy, docker и так требование к машине разработчика | Каталог точек входа не растёт ни на один пакет | Ещё один контейнер в локальном прогоне и конфиг прокси, который никто не проверяет; отладка «почему не узнан» уходит в чужой контейнер |
|
||||
| **Ключ конфига, подставляющий имя** | Один процесс вместо двух | В боевом коде появляется ветка, пускающая без всякой проверки; от беды её отделяет только правильность конфига на сервере. Сверх того — **прямо противоречит требованию дельты**: сервис узнаёт пришедшего по заголовку с доверенного адреса и никак иначе, значит цена включает переоткрытие спеки |
|
||||
|
||||
**Решено человеком на чекпоинте 2026-08-22:** первый вариант — один пакет
|
||||
оснастки `cmd/devtools` с подкомандами. Эта работа заводит пакет и подкоманду
|
||||
`proxy`; подкоманду `admin` заводит задача `dev-run-task`, и её запись надо
|
||||
переформулировать — она планировала свой отдельный пакет. Строка исключения в
|
||||
сборке образа остаётся одна навсегда.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Весь барьер держится на том, что прокси заголовок перезаписывает, а не
|
||||
пропускает пришедший.** Запрос от анонима приходит к сервису с адреса прокси,
|
||||
то есть с доверенного; если прокси не заменит `Remote-User` своим значением, а
|
||||
оставит присланный, сервис пустит кого угодно под любым именем. Проверить это
|
||||
репозиторием нечем — правило живёт в `pet-project-server`. → Требование к
|
||||
контуру называется поимённо в модели угроз, и выкладку запускает человек.
|
||||
- **Правило Authelia на домен сервиса не заведено.** Тогда прокси заголовка не
|
||||
ставит вовсе, сервис никого не узнаёт, и все адреса приложения отвечают `401`.
|
||||
→ Отказ громкий и одинаковый для всех, а перечень доверенных адресов сервис
|
||||
называет строкой журнала при подъёме.
|
||||
- **Переименование пользователя в Authelia заводит новую учётную запись**, и
|
||||
прежние записи остаются у прежней. → Принятая цена, названная в постановке;
|
||||
записывается в спеку, а не обходится.
|
||||
- **Два первых обращения одним именем одновременно.** Оба не найдут записи и оба
|
||||
примутся её заводить. → Уникальный индекс отвергает второго; код на отказ
|
||||
уникальности повторяет поиск, а не падает.
|
||||
- **Учётная запись заводится анонимом, если прокси настроен неверно.** Прежде
|
||||
запись заводилась только успешным входом у провайдера. → Тот же риск, что и
|
||||
первый, и то же смягчение: заводит её обращение с доверенного адреса, а
|
||||
доверенный адрес — это прокси.
|
||||
- **Перечень доверенных адресов, накрывающий весь интернет**, читается как
|
||||
подсеть и стартует молча. Старт роняется на пустом и на нечитаемом перечне, но
|
||||
«доверять всякому» остаётся достижимым настройкой — тем самым, что дизайн
|
||||
отверг решением. → Смягчения барьером нет намеренно: порог «эта подсеть
|
||||
слишком широка» обходится двумя половинками той же подсети, то есть был бы
|
||||
барьером на вид. Наблюдаемость вместо барьера: перечень называется строкой
|
||||
журнала при подъёме.
|
||||
- **Логин у провайдера переиспользуем**, и новый его владелец получает архив
|
||||
прежнего. → Записывается ценой в спеку и в модель угроз; не допускать
|
||||
переиспользования — работа провайдера. Подробно — Р5а.
|
||||
- **Панель владельца остаётся вне разграничения.** Она и была вне его; барьер
|
||||
переезжает с правила на литерал пути (обходимого подменой знака) на домен,
|
||||
закрытый Authelia. → Обход `/%5f/` перестаёт существовать, но канонизация пути
|
||||
этой работой не делается, и в модели угроз это надо переписать, а не вычеркнуть.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Стройка: на сервере данных нет, выкладка пойдёт с чистого листа, переносить
|
||||
нечего.
|
||||
|
||||
1. Новый шаг схемы — колонка ключа `provider_login` с уникальным индексом,
|
||||
необязательная почта, снятые настройки OAuth2 и **все правила доступа
|
||||
коллекции пользователей, снятые в пустое**. Применённый
|
||||
`202608120001_oidc_login` не трогается.
|
||||
2. Конфиг: секция входа переписывается, образец — вместе с ней.
|
||||
3. Контур: правило прокси и правило Authelia для домена сервиса заводятся в
|
||||
`pet-project-server`. Это делает человек, и до этого сервис никого не узнаёт.
|
||||
|
||||
Откат: шаг схемы возвращает то, что было до него, кроме открытого создания
|
||||
записи — его не возвращает и прежний шаг, по той же причине.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Открытых не осталось: оба вопроса закрыты человеком на чекпоинте 2026-08-22 —
|
||||
имя ключа настроек (Р4) и способ представиться без прокси (Р8).
|
||||
@@ -0,0 +1,71 @@
|
||||
## Why
|
||||
|
||||
Сервис ведёт вход своими руками: уводит человека к провайдеру, помнит выданное
|
||||
состояние у браузера, меняет принесённый код на сессию и потом семь суток верит
|
||||
выданной куке, ни разу больше не спросив провайдера. Отзыв доступа доходит до
|
||||
сервиса только истечением этой куки — то есть с задержкой до недели, — а секрет
|
||||
клиента ради обмена лежит и в конфиге, и в базе.
|
||||
|
||||
Обратный прокси между интернетом и сервисом уже спрашивает Authelia на **каждом**
|
||||
запросе и называет пришедшего заголовком: трём соседним сервисам того же контура
|
||||
он так и служит. Сервису достаточно этот заголовок прочитать, и весь собственный
|
||||
протокол входа становится лишним.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING** Кто пришёл, сервис узнаёт из заголовка, поставленного обратным
|
||||
прокси. Своего входа у него не остаётся вовсе: ни адреса, уводящего к
|
||||
провайдеру, ни возврата, ни куки, ни выхода.
|
||||
- **BREAKING** Заголовку верят только с адреса, объявленного доверенным.
|
||||
Пришедший с любого другого адреса тем же заголовком не узнаётся никак.
|
||||
- Учётная запись заводится сама, при первом обращении с новым именем, и
|
||||
находится по нему же при каждом следующем. Имя из заголовка становится ключом
|
||||
учётной записи и живёт своей колонкой.
|
||||
- **BREAKING** Секция настроек входа переписывается целиком: адреса провайдера,
|
||||
идентификатор и секрет клиента уходят, приходит перечень доверенных адресов.
|
||||
- Секрет клиента исчезает и из настроек хранилища. Вместе с ним снимается изъятие
|
||||
из инварианта «Секрет не покидает конфиг»: чтение файла базы больше не
|
||||
равносильно чтению секрета.
|
||||
- Отзыв доступа перестаёт ждать семи суток: Authelia судит каждый запрос, а
|
||||
сервис назначенного срока сессии больше не держит вовсе.
|
||||
- Панель владельца закрывается доменом, а не правилом на литерал пути, и обход
|
||||
подменой знака в адресе перестаёт существовать. Половина этой работы живёт в
|
||||
контуре выкладки, вне репозитория.
|
||||
- Способ представиться на машине без прокси заводится заново: подставной
|
||||
провайдер уходит вместе с протоколом.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет: работа меняет то, как узнаётся пришедший, а не заводит новое
|
||||
поведение.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `access`: узнавание пришедшего переезжает с собственного входа у внешнего
|
||||
провайдера на заголовок доверенного источника. Уходят требования о протоколе
|
||||
входа, о куке сессии, о сроке её жизни, о выходе, о продлении и о секрете
|
||||
провайдера в конфиге; приходят требования о доверенном источнике, о заголовке
|
||||
как имени пришедшего и о заведении учётной записи первым обращением.
|
||||
Требования о владельце записи, об открытых адресах и о том, как приложение
|
||||
узнаёт вошедшего, остаются по существу и правятся в формулировках.
|
||||
|
||||
## Impact
|
||||
|
||||
- Транспорт HTTP: обработчики входа и возврата, слой предъявления куки, слой
|
||||
запрета продления, корень `/auth` целиком.
|
||||
- Хранилище: приведение настроек провайдера к конфигу при подъёме, новый шаг
|
||||
схемы — колонка имени из заголовка, снятие настроек OAuth2 и правила создания
|
||||
записи. Применённый шаг `202608120001_oidc_login` не переписывается.
|
||||
- Настройки: секция входа в конфиге и в его образце. Имена ключей — необратимое,
|
||||
и решение по ним принимает человек.
|
||||
- Приложение: адрес, которым экран уводил ко входу.
|
||||
- Инструменты: подставной провайдер OIDC уходит, на его место встаёт способ
|
||||
представиться без прокси. На него опирается задача `dev-run-task`.
|
||||
- Документы: модель угроз — периметр, недоверенный вход, разграничение доступа,
|
||||
сдвиг про секрет в базе; `CLAUDE.md` — изъятие из инварианта о секрете;
|
||||
устройство и схема хранилища.
|
||||
- Вне репозитория: правило обратного прокси и правило Authelia для домена
|
||||
сервиса живут в `pet-project-server`. Этой работой они не проверяются, но
|
||||
требование к ним она обязана назвать.
|
||||
@@ -0,0 +1,511 @@
|
||||
# Ревью изменения `trusted-header-login`
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Режим прогона:** по графу. **Метка:** `large`.
|
||||
*Размер и сложность в задании триажу не переданы* — обоснование разметки
|
||||
воспроизвести нечем, метка взята как объявленная.
|
||||
- **База диффа:** `origin/master`. Изменение читалось вместе с непрослеженными
|
||||
файлами (`cmd/devtools/`, `internal/adapter/repo/pocketbase/identity.go`,
|
||||
`migrations/202608220001_trusted_header_login.go`,
|
||||
`internal/controller/http/identity.go`, дельта-спеки change).
|
||||
- **Состояние гейта:** зелёный целиком, кроме шага `dockerfile` — `hadolint`
|
||||
даёт `DL3066` на `Dockerfile:94`. Отказ **унаследован** (воспроизведён на
|
||||
чистом `origin/master`), долг объявлен строкой в `CLAUDE.md`, раздел «Гейт».
|
||||
Новым красным шагом это изменение гейт не красит.
|
||||
- **На входе:** 31 пронумерованная находка проходов + 4 пункта «дешевле
|
||||
переделать до мерджа» от `architecture` + 1 замер без дефекта от `ops`.
|
||||
**На выходе:** 3 + 4 = 7 в основных секциях, остальное — ниже, ничего не
|
||||
выброшено молча.
|
||||
|
||||
### План с исходом по каждой теме
|
||||
|
||||
| тема | дом | глубина | кто закрывает | исход |
|
||||
|---|---|---|---|---|
|
||||
| requirements | дельта-спеки change + `openspec/specs/` | разбор | `specs` | закрыта, 8 находок (5–12); в отчёт ушли 2, ещё 4 — ниже |
|
||||
| autotests | `CLAUDE.md`, «Гейт» | — | `autotests` | закрыта, 4 находки (1–4); в отчёт не ушла ни одна самостоятельно, одна закрывается правкой №3 |
|
||||
| conventions | `docs/conventions/` | разбор | `code` | закрыта, 8 находок (15–22); в отчёт ушли 2 |
|
||||
| architecture | `docs/architecture.md` + `docs/passport.md` | доказательство | `architecture` | закрыта, 2 находки + 4 пункта «до мерджа»; в отчёт ушла 1 |
|
||||
| security | `docs/security.md` | доказательство | `adversary` | закрыта, 6 находок (23–28); в отчёт ушли 3 |
|
||||
| operations | `docs/architecture.md` «Эксплуатация» + `docs/database.md` | доказательство | `ops` | закрыта, 3 находки + 1 замер без дефекта; в отчёт не ушла ни одна, старшая — в гипотезах |
|
||||
|
||||
- **Тем без отчёта нет.** Тем без дома в плане нет.
|
||||
- **`basics` не запускался** — своих тем проекта нет, все шесть разобраны
|
||||
именными проходами.
|
||||
- **Сигнал о заниженной метке:** `review-code` отработал и возражений по метке
|
||||
**не подал**; `review-basics` не запускался, поднять метку было некому.
|
||||
Молчание здесь — молчание одного корректора из двух, а не двух из двух.
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### Запрос, отвергнутый ограничителем частоты, всё равно заводит учётную запись
|
||||
|
||||
- Файл: `internal/controller/http/identity.go:91`, `internal/controller/http/errors.go:219`
|
||||
- Severity: major (`critical` не ставлю: инвариант `CLAUDE.md` этим не нарушен, а
|
||||
путь ведёт к мусору в коллекции, а не к порче чужих данных)
|
||||
- Confidence: high
|
||||
- Оракул: **свой падающий тест, прогнан на этом прогоне.** Слой узнавания стоит
|
||||
на `DefaultLoadAuthTokenMiddlewarePriority + 1` = **−1019**, ограничитель
|
||||
библиотеки — на **−1000** (`apis/middlewares_rate_limit.go:16`), то есть
|
||||
узнавание отрабатывает **до** ограничителя. Прогон: 120 запросов выбирают
|
||||
бюджет, следующие 50 запросов новыми логинами получают `429` — **все 50**, — а
|
||||
учётных записей в коллекции становится `2 → 52`.
|
||||
- Последствие: ограничитель частоты не защищает **ничего** из того, что делает
|
||||
слой узнавания. Каждый отвергнутый запрос — это как минимум чтение из базы
|
||||
(поиск по логину идёт на каждом запросе по норме `access`), а с новым именем —
|
||||
ещё и запись. Учётная запись, заведённая мусором, из сервиса не убирается:
|
||||
спека `access` называет цену прямо — «слить их или убрать нечем». Барьер
|
||||
«заголовок ставит прокси» этот путь сужает, но не закрывает: `docs/review.md`,
|
||||
«Недоступно проверке», записывает поведение прокси как непроверяемое отсюда, и
|
||||
строить на нём защиту базы значит держать один барьер вместо двух.
|
||||
- Предложение: перенести слой узнавания на
|
||||
`apis.DefaultRateLimitMiddlewarePriority + 1` (−999), а `RequireUser` — на
|
||||
`+ 2` (−998). Порядок «токен (−1020) → ограничитель (−1000) → узнавание (−999)
|
||||
→ требование записи (−998) → предел тела (−990)» сохраняется целиком.
|
||||
- Найдено проходом: `adversary` (25); подтверждено собственным прогоном триажа.
|
||||
- Действие: **инлайн**
|
||||
|
||||
> **Ответ на первый вопрос задания — перестановка проверена запуском.** С
|
||||
> приоритетами −999/−998 прогнан **весь** `go test ./...`: единственным красным
|
||||
> остался мой временный тест на находку про общий бюджет (ниже, №4), все
|
||||
> существующие проверки пакета `internal/controller/http` — включая оба критерия
|
||||
> приёмки, победу предъявленного токена над заголовком и «проба здоровья
|
||||
> учётной записи не заводит» — прошли. Заведение записи после `429` при этом
|
||||
> пропало: `2 → 2`. Предел тела на −990 действительно остаётся после требования
|
||||
> учётной записи, «отказ неузнанному до чтения тела» сохраняется.
|
||||
>
|
||||
> **Одно последствие перестановки задание не называет, и оно настоящее.** Сейчас
|
||||
> `RequireUser` (−1018) отказывает неузнанному **до** ограничителя, и отказы
|
||||
> бюджета не тратят. После переноса — тратят: замер на этом прогоне, 125
|
||||
> запросов без заголовка, и следующий запрос узнанного человека получает `429`
|
||||
> (до перестановки — `200`). Само по себе это правильнее (счёт отказов и есть
|
||||
> работа ограничителя), но вместе с общим бюджетом (№4) оно даёт анониму,
|
||||
> дотянувшемуся до контейнера, выключение сервиса для всех. Поэтому №1 и №4
|
||||
> едут одной правкой, а не порознь.
|
||||
|
||||
### Ключ учётной записи правится рукой в панели, и архив уезжает следующему с этим именем
|
||||
|
||||
- Файл: `internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go:77-82`,
|
||||
`internal/adapter/repo/pocketbase/panel.go:33-34`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: дословное требование дельта-спеки
|
||||
`openspec/changes/trusted-header-login/specs/access/spec.md`: «**Ключ учётной
|
||||
записи MUST не правиться ничем, кроме заведения самим сервисом.** Ни запросом
|
||||
снаружи, ни рукой в панели». Плюс
|
||||
`grep -rn "OnRecordUpdateRequest" --include=*.go internal/ cmd/` → единственная
|
||||
привязка стоит на `migrations.RecordsCollection`, на `users` хука нет. Шаг
|
||||
схемы закрывает правила API (`UpdateRule = nil`), а панель работает
|
||||
суперпользователем — это записано комментарием самого шага, строки 48–50.
|
||||
- Последствие: переписанный в панели `provider_login` отдаёт весь архив прежнего
|
||||
владельца тому, кто придёт с этим именем следующим. Владелец записи
|
||||
назначается один раз и не меняется — вернуть архив нечем. Правка своя,
|
||||
сознательная, но необратимая и **молчаливая**: журнала событий у коллекции
|
||||
пользователей нет.
|
||||
- Предложение: развилка ниже.
|
||||
- Найдено проходом: `specs` (6)
|
||||
- Действие: **развилка**
|
||||
|
||||
> **Вопрос владельцу.** Требование спеки «ключ не правится рукой в панели»
|
||||
> сегодня не держит ничто: правила API закрыты, но панель ходит
|
||||
> суперпользователем. Три варианта:
|
||||
> **(А)** завести хук `OnRecordUpdateRequest(users)`, отвергающий смену
|
||||
> **непустого** `provider_login` — в проекте уже есть тот же приём для записей
|
||||
> (`pbrepo.BindPanelRules`), цена — десяток строк и проверка;
|
||||
> **(Б)** переписать требование, назвав панель доверенной и записав цену
|
||||
> («ключ правится владельцем сервиса, откатить правку нечем»);
|
||||
> **(В)** оставить как есть — тогда спека и код расходятся молча, и это
|
||||
> расхождение переживёт мердж.
|
||||
|
||||
### Заведение учётной записи не оставляет в журнале ни строки, а поломка контура пишется на уровне, которого в бою нет
|
||||
|
||||
- Файл: `internal/controller/http/identity.go:101-108,128-151`,
|
||||
`internal/adapter/repo/pocketbase/identity.go:52-87`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: **свой прогон.** Запрос новым логином с доверенного адреса →
|
||||
ответ `200`, учётная запись заведена, перехваченный журнал — **пустая строка**
|
||||
(`""`). Дословное требование дельта-спеки `access`: «Исход узнавания MUST
|
||||
оставлять строку журнала — и когда заголовок пришёл с недоверенного адреса, и
|
||||
**когда учётная запись заведена**. […] Строка несёт адрес пира и идентификатор
|
||||
учётной записи». Второй симптом: ветвь «более одного значения `Remote-User`»
|
||||
(строки 103–106) пишет `Debug`, боевой уровень — `Info`
|
||||
(`docs/conventions/logging.md`, «Расхождение: уровень зашит константой»).
|
||||
- Последствие: сервис заводит учётные записи молча — владелец не отличит «никто
|
||||
не заходил» от «завелось двадцать записей», а по спорной учётной записи не
|
||||
скажет, когда и с какого адреса она появилась. Хуже второе: `docs/security.md`
|
||||
называет прокси, добавляющий заголовок вместо замены, **главным** барьером
|
||||
контура, и половину этой беды сервис закрывает сам — но закрывает **невидимо**:
|
||||
строка о двух значениях в боевом журнале не появляется вовсе. Поломка контура,
|
||||
которую сервис поймал, владельцу неотличима от тишины.
|
||||
- Предложение: `logger.Info` на исходе «учётная запись заведена» — с адресом
|
||||
пира и идентификатором записи, **без** значения заголовка (инвариант
|
||||
«Содержимое записи остаётся приватным» и требование спеки «MUST не нести
|
||||
значения заголовка»); уровень ветви «два значения» поднять до `Warn` — ровно
|
||||
как у ветви недоверенного пира, по той же причине и с той же ценой.
|
||||
- Найдено проходами: `specs` (5) и `adversary` (26) — две находки об одной
|
||||
причине, слиты; **оракула у согласия проходов нет, оракул свой**.
|
||||
- Действие: **инлайн**
|
||||
|
||||
---
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### Бюджет ограничителя частоты общий на весь сервис: один человек выключает сервис остальным
|
||||
|
||||
- Файл: `internal/controller/http/rate_limit.go:20-23`, `internal/controller/http/app.go:43-63`,
|
||||
`internal/config/config.go:91-96`, `cmd/transcriber/main.go:238-246`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: **свой падающий тест.** 125 запросов первого человека выбирают бюджет,
|
||||
**первый** запрос второго человека получает `429`. Причина — в исходниках
|
||||
библиотеки: `checkRateLimit` берёт ключом `e.RealIP()`
|
||||
(`apis/middlewares_rate_limit.go`), а `RealIP()` при пустом
|
||||
`Settings.TrustedProxy.Headers` откатывается к адресу пира
|
||||
(`core/event_request.go:40-75`). Пир теперь **всегда** обратный прокси.
|
||||
- Последствие: объявленная сервисом частота опроса выводится из доли бюджета
|
||||
(`pollBudgetShare = 8`, `PollIntervalMs = 4000`) в расчёте на то, что бюджет
|
||||
делят немногие. Делят его **все**: восемь одновременных опросов выбирают его
|
||||
целиком, и девятый человек получает `429` на пустом месте. Отказ шумный, но
|
||||
причина невидима — по журналу он неотличим от собственной активности.
|
||||
- **Дефект старше этой задачи** (правило заведено `spa-skeleton` 2026-08-15,
|
||||
прокси перед сервисом стоял и тогда). Уточнение к формулировке прохода:
|
||||
`docs/database.md:344` и `app.go:43-49` **не** утверждают обратного — там
|
||||
прямо написано «бюджет считается по адресу спрашивающего, а не по учётной
|
||||
записи», и даже назван случай «двое за одним домашним адресом делят его
|
||||
пополам». Сломалось не правило, а **основание** правила: «адрес
|
||||
спрашивающего» выродился в один адрес на всех, и число 120/60 выбиралось не
|
||||
под это.
|
||||
- Найдено проходами: `architecture` (13) и `adversary` (23); подтверждено
|
||||
собственным прогоном.
|
||||
- Действие: **развилка**
|
||||
|
||||
> **Ответ на второй вопрос задания: чинить здесь.** Не потому, что дефект
|
||||
> этой задачи — он не её, — а потому, что правка №1 делает его достижимым для
|
||||
> **анонима**: после переноса `RequireUser` за ограничитель отказы неузнанному
|
||||
> начинают тратить общий бюджет (замерено на этом прогоне: 125 анонимных
|
||||
> запросов → узнанный человек получает `429`). Мерджить №1 без ответа на №4
|
||||
> значит завести новый путь к отказу сервиса.
|
||||
>
|
||||
> **Вопрос владельцу.** Чем считать бюджет ограничителя, когда весь трафик
|
||||
> приходит с одного адреса:
|
||||
> **(А)** заполнять `Settings.TrustedProxy.Headers` при подъёме (там же, где
|
||||
> `ApplyAppRateLimit`) — тогда ключом станет адрес человека из
|
||||
> `X-Forwarded-For`. Доверие к этому заголовку той же природы, что доверие к
|
||||
> `Remote-User`, и опирается на тот же перечень адресов; цена — ещё одно
|
||||
> требование к контуру, которое отсюда не проверить;
|
||||
> **(Б)** признать бюджет общим на сервис: поднять числа под ожидаемое число
|
||||
> людей, переписать `docs/database.md` и обоснование `pollBudgetShare`;
|
||||
> **(В)** отложить в урожай и мерджить №1 с известным ухудшением — тогда
|
||||
> анонимный поток через прокси выключает приложение всем.
|
||||
|
||||
### Негодное значение `Remote-Name` запирает человека в сервисе навсегда пятисоткой
|
||||
|
||||
- Файл: `internal/adapter/repo/pocketbase/identity.go:71-87,147-159`,
|
||||
`internal/controller/http/identity.go:133-147`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: **свой прогон.** Запрос с доверенного адреса, `Remote-User: namebearer`,
|
||||
`Remote-Name` из 5000 знаков → ответ `500`
|
||||
`{"error_code":"internal","message":"Внутренняя ошибка сервиса"}`, в журнале
|
||||
`level=ERROR msg="Failed to resolve account by login header" error="failed to
|
||||
create user account: name: Must be no more than 255 character(s).."`.
|
||||
Значение заголовка в ответ и в журнал при этом **не** уехало — инвариант
|
||||
приватности цел.
|
||||
- Последствие: приёму подвергается только логин (`AcceptProviderLogin`), а имя и
|
||||
почта уезжают в колонку как есть. Человек, чьё имя у провайдера длиннее 255
|
||||
знаков, получает `500` на **каждом** запросе и в сервис не попадёт никогда;
|
||||
владелец получает `ERROR` на каждый такой запрос. Смежный симптом той же
|
||||
причины: разбор отказа судит по **наличию колонки** в `validation.Errors`, а не
|
||||
по причине отказа — негодный адрес почты неотличим от занятого, и запись молча
|
||||
заводится без почты вместо честного разбора.
|
||||
- Предложение: привести имя и почту к годным значениям в одном месте с логином —
|
||||
имя усечь до предела колонки, негодную почту не ставить вовсе. Этой же правкой
|
||||
закрывается непокрытая ветвь «отказ хранилища → отказ сервиса»
|
||||
(`identity.go:144-146`), которую `autotests` называет единственным намеренным
|
||||
исключением из «слой отказа не выдаёт»: у неё появляется достижимый и
|
||||
проверяемый вход.
|
||||
- Найдено проходами: `specs` (7, 10), `code` (15, 16), `adversary` (24) — пять
|
||||
формулировок, две причины, одно место правки; слиты. Согласие пяти проходов
|
||||
`Confidence` не повышает — оракул свой.
|
||||
- Действие: **инлайн**
|
||||
|
||||
### Образец конфига велит верить всей подсети docker: любой контейнер на хосте входит под любым именем
|
||||
|
||||
- Файл: `config.example.toml:69-77`
|
||||
- Severity: major
|
||||
- Confidence: medium (путь не построен: второго контейнера в прогоне нет)
|
||||
- Оракул: положение `docs/security.md:109` дословно — источник заголовков есть
|
||||
«Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного
|
||||
адреса**». Образец задаёт этим адресом `172.16.0.0/12`, то есть весь
|
||||
умолчательный диапазон docker: под него попадает любой контейнер на хосте, а
|
||||
не только Caddy.
|
||||
- Последствие: барьер узнавания держится **только** на адресе пира (кук и
|
||||
токенов сервис не выдаёт). Любой сосед по хосту — а хост держит пет-проекты, а
|
||||
не один сервис — ставит `Remote-User: <чужой логин>` и получает чужой архив
|
||||
целиком, молча. Класс риска в `security.md` назван, ширина перечня — нет.
|
||||
- Предложение: развилка ниже.
|
||||
- Найдено проходом: `adversary` (27)
|
||||
- Действие: **развилка**
|
||||
|
||||
> **Вопрос владельцу.** Чем сузить перечень доверенных адресов до самого
|
||||
> прокси:
|
||||
> **(А)** выделенная сеть compose с фиксированной подсетью под пару
|
||||
> «прокси ↔ сервис» — правка живёт в `pet-project-server`, здесь остаётся
|
||||
> значение образца и строка требования к контуру;
|
||||
> **(Б)** статический адрес прокси в сети docker — то же, но проще и хрупче;
|
||||
> **(В)** принять `/12` как цену и записать её строкой в `docs/security.md`
|
||||
> рядом с уже названным классом — тогда это осознанная цена, а не умолчание
|
||||
> образца.
|
||||
|
||||
### Три конвенции проекта описывают снятый механизм, и одна противоречит себе внутри файла
|
||||
|
||||
- Файл: `docs/conventions/web-ui.md:107-109`, `docs/conventions/config.md:60-64` против `:99`,
|
||||
`docs/conventions/logging.md:34-37`
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: чтение файлов на этом прогоне. `web-ui.md`: «**Сессия живёт кукой
|
||||
`transcriber_session`**» — кук сервис больше не ставит вовсе. `config.md`
|
||||
строка 60–64: «Секретов в этой секции больше нет», строка 99 в том же файле
|
||||
по-прежнему перечисляет `auth.client_secret` среди секретов. `logging.md`
|
||||
строки 34–37 описывают расхождение у пакета `cmd/oidcstub`, который этим же
|
||||
изменением удалён (`git status` → `D cmd/oidcstub/main.go`).
|
||||
- Последствие: конвенции — канон проекта, по ним работает следующая задача.
|
||||
Запись про куку заставит следующего агента искать куку и объяснять её
|
||||
отсутствие; запись про `client_secret` заставит охранять секрет, которого нет.
|
||||
Машина этого не ловит: согласованность документов между собой и с кодом судят
|
||||
агенты, и звать их надо руками (`CLAUDE.md`, «Гейт»).
|
||||
- Предложение: снять три записи, поправить перечень секретов в `config.md`.
|
||||
- Найдено проходом: `code` (20)
|
||||
- Действие: **инлайн**
|
||||
|
||||
---
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
Понижено и не влезло в потолок; каждая с причиной понижения.
|
||||
|
||||
- **Откат бинаря поверх шага схемы `202608220001` обрывает вход** (`ops`, 29;
|
||||
было `major`). Оракул у прохода настоящий — падающий тест на восстановленном
|
||||
старом коде во временном worktree, `401` вместо `302`. Понижаю до `minor`
|
||||
**по стадии и по перекрытию**: (1) стадия — стройка, сервис на сервере
|
||||
остановлен, данных нет, откатываться не с чего; (2) `docs/architecture.md:142`
|
||||
уже объявляет откат неработающим начиная с шага `202608140002` — то есть любой
|
||||
бинарь старше 2026-08-14 и так не поднимается; новое окно отката — только
|
||||
версии между 14 и 22 августа, ни одна из которых не выкладывалась; (3) старый
|
||||
бинарь на новом конфиге и вовсе не стартует, если владелец убрал ключи OIDC по
|
||||
процедуре из «Эксплуатации» — тогда отказ громкий, а не молчаливый.
|
||||
**Ответ на третий вопрос задания: да, стадия меняет серьёзность.** Правка тем
|
||||
не менее дешёвая и уместная — строка в `docs/architecture.md`, «Эксплуатация»,
|
||||
рядом с уже стоящей про `202608140002`: не потому что откат сегодня возможен, а
|
||||
потому что порог перехода там принято называть прямо.
|
||||
- **Уникальный индекс по `provider_login` не частичный** (`code`, 17). Оракул
|
||||
прохода — исходники библиотеки (`core/collection_model.go:648-671`, индекс
|
||||
собирается без `WHERE`, сосед по коллекции — `WHERE email != ''`). Понижаю:
|
||||
сервис пустого логина в колонку не кладёт никогда (`AcceptProviderLogin`
|
||||
отвергает), коллекция пользователей на выкладке пуста, и последствие сводится
|
||||
к «в панели рукой не завести вторую запись без логина». Промах будущего, не
|
||||
дефект сегодня.
|
||||
- **Предел логина считается в байтах, колонка — в знаках** (`specs`, 8;
|
||||
`code`, 18). Проверено чтением: `len(login) > 255` в
|
||||
`identity.go:174` против `Max: 255` у текстовой колонки. Понижаю: перекос
|
||||
только в сторону строгости, кириллический логин длиннее 127 знаков у Authelia
|
||||
не встречается, следа `Debug` хватает. Пути нет — гипотеза.
|
||||
- **Склейка значений заголовка запятой обходит защиту «два значения»**
|
||||
(`adversary`, 28). Пути нет и построить его отсюда нечем: `net/http` заголовки
|
||||
запятой не сливает, а прокси, который сливает, — ровно тот класс, который
|
||||
`docs/review.md` относит к «не проверит ни один проход». Последствие при этом
|
||||
было бы неприятным и тихим (человек попадает в **новую** пустую учётную
|
||||
запись вместо своей), поэтому строка оставлена, а не выброшена.
|
||||
- **Откат шага схемы не восстанавливает `OAuth2.Enabled` и минирует записи без
|
||||
почты** (`specs`, 9; `ops`, 30). Оракул у `ops` есть — прогон на временной
|
||||
базе, `email: cannot be blank`. Понижаю по тому же основанию, что и №29: `down`
|
||||
на сервере не зовётся, а применённый шаг не переписывается по инварианту.
|
||||
- **Отказ хранилища при узнавании не покрыт ничем** (`autotests`, 2). Не
|
||||
выведена отдельной строкой: правка про негодное имя даёт этой ветви
|
||||
достижимый вход, и тест на неё пишется той же правкой. Если владелец выберет
|
||||
не чинить негодное имя — эта строка возвращается самостоятельной находкой.
|
||||
- **Непокрытые ветви `retryAfterConflict` и `web/src/api.ts`** (`autotests`, 1,
|
||||
3, 12). Понижаю по записанному решению проекта: `CLAUDE.md`, «Гейт» —
|
||||
«покрытие изменённых строк не считается ничем». Своего `api.test.ts` у нового
|
||||
`web/src/api.ts` действительно нет (проверено `ls web/src/`), и это стоит
|
||||
завести — но это задача, а не блокирующая находка.
|
||||
|
||||
---
|
||||
|
||||
## Promote candidates
|
||||
|
||||
Претензии на правило, а не на этот код.
|
||||
|
||||
- **Поле `peer` заведено мимо словаря имён журнала** (`code`, 21).
|
||||
`docs/conventions/logging.md:99` требует точечной иерархии для системных
|
||||
доменов (`http.*`, `ext.*`, `webapp.*`), а в файле уже стоит *Расхождение* с
|
||||
перечнем самозаведённых имён. Кандидат в конвенцию: внести `http.peer_addr` в
|
||||
таблицу полей — тогда следующий агент возьмёт имя из словаря, а не сочинит
|
||||
третье.
|
||||
- **Третий способ вывода в репозитории** (`code`, 22). `cmd/devtools` печатает
|
||||
через stdlib `log`, тогда как `logging.md` знает два. Записанной конвенции об
|
||||
оснастке у проекта нет — значит это кандидат в правило, а не нарушение.
|
||||
- **Единственный дом имён заголовков `Remote-*`** (`architecture`, 14).
|
||||
`cmd/devtools/main.go:92-97` пишет `"Remote-User"`, `"Remote-Name"`,
|
||||
`"Remote-Email"` своими литералами, тогда как нормативные константы лежат в
|
||||
`internal/controller/http/identity.go:23-27`. Правка на две строки, но правило
|
||||
общее — «нормативное имя объявляется один раз» — и его стоит записать, а не
|
||||
чинить точечно. Смежный вопрос `docs/review.md`, «Вопросы по темам»: не завёл
|
||||
ли инструмент разработчика второй дом тому, что уже есть в проверках.
|
||||
- **Второй словарь текстов на клиенте** (`code`, 19). Половина находки —
|
||||
ложноположительная: `docs/conventions/web-ui.md:118` **сам** разрешает
|
||||
клиентский текст «связи нет» состоянием, а не ошибкой. Настоящий остаток —
|
||||
расхождение текстов: сервер на `401` отвечает «Требуется вход» про вход,
|
||||
которого у сервиса больше нет, а экран показывает свой текст поверх. Кандидат
|
||||
в правило: конвенция сегодня не различает «текст под код ответа» и «текст под
|
||||
состояние клиента», и на этом различии находка и разошлась.
|
||||
|
||||
---
|
||||
|
||||
## Урожай
|
||||
|
||||
Настоящее, но не для этого мерджа.
|
||||
|
||||
- **Бюджет ограничителя частоты по человеку, а не по адресу** — если владелец
|
||||
выберет вариант (В) на развилке №4.
|
||||
- **Секция конфига называется `[auth]` при отсутствии всякого входа**
|
||||
(`architecture`). Имя ключа конфига — из перечня необратимого в `CLAUDE.md`
|
||||
(«спрашивается у человека всегда»), но **сегодня стадия стройки**: на сервере
|
||||
ничего нет, и переименование стоит ноль. После первой выкладки — уже решение
|
||||
человека. Если переименовывать, то сейчас.
|
||||
- **Колонка почты без единого читателя** (`architecture`). Почта пишется при
|
||||
заведении и не читается ничем. Не дефект: спека объявляет её необязательной и
|
||||
берёт «при заведении». Но колонка без читателя — долг, который стоит либо
|
||||
оправдать спекой, либо снять.
|
||||
- **Корень `/auth` перестал быть зарезервированным** (`architecture`). Проверено:
|
||||
упоминаний `/auth` в `app.go` больше нет. Освободившееся адресное пространство
|
||||
стоит либо занять, либо назвать свободным в спеке `webapp`.
|
||||
- **Свой тест у `web/src/api.ts`** — файл новый, разбор отказов в нём есть,
|
||||
проверки нет.
|
||||
- **Строка про порог отката в `docs/architecture.md`, «Эксплуатация»** — см.
|
||||
гипотезу №29.
|
||||
|
||||
---
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
### План: темы, дома, глубины
|
||||
|
||||
Воспроизведён таблицей в сводке выше целиком. Тем без дома в плане нет, тем без
|
||||
отчёта нет.
|
||||
|
||||
### Что запускалось
|
||||
|
||||
- Режим: по графу, метка `large`. Запущены `specs`, `autotests`, `code`,
|
||||
`architecture`, `adversary`, `ops`.
|
||||
- **`basics` не запускался** — своих тем проекта у него на этом прогоне нет, все
|
||||
шесть тем разобраны именными проходами. Следствие: сигнал о заниженной метке
|
||||
мог подать только `review-code`, и он его не подал.
|
||||
|
||||
### Чего не хватило триажу, и это находки о прогоне
|
||||
|
||||
- **Блоков «Coverage of this pass» в сырых выводах нет ни у одного прохода.**
|
||||
Контракт находок требует их у каждого, и сводить мне было нечего: строку «что
|
||||
этот проход не мог проверить в принципе» я по каждому проходу воспроизвести не
|
||||
могу. Ниже — только то, что выводится из плана и из документов проекта.
|
||||
- **Сработавшие потолки ни один проход не назвал.** Сколько находок проход
|
||||
показал, каков был его потолок и что осталось за срезом — не сообщено никем.
|
||||
Значит утверждать «проход показал всё, что нашёл» нельзя ни про один из шести.
|
||||
- **Размер и сложность изменения триажу не переданы** — только метка.
|
||||
Обоснование разметки в отчёте воспроизвести нечем.
|
||||
|
||||
### Что осталось целиком на человеке
|
||||
|
||||
Из `docs/review.md`, «Недоступно проверке». **Два списка, и они не сливаются.**
|
||||
|
||||
**Не проверит ни один проход:**
|
||||
|
||||
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
|
||||
SpeechKit и Object Storage поднять в тесте нечем;
|
||||
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
|
||||
условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу;
|
||||
- `security`: **поведение настоящей Authelia и правило обратного прокси на домен
|
||||
сервиса.** С 2026-08-22 от прокси зависит **весь** барьер: он обязан заголовки
|
||||
`Remote-*` перезаписывать, а не пропускать пришедшие. Правило живёт в
|
||||
`pet-project-server`, и отсюда его не проверить. Прямо на этом висят находки
|
||||
№1 (насколько узок путь к заведению мусорных записей), №6 (ширина перечня
|
||||
доверенных адресов) и гипотеза про склейку запятой;
|
||||
- `security`: поведение браузера с куками — своих кук сервис больше не ставит,
|
||||
класс сузился до кук панели хранилища.
|
||||
|
||||
**Перестали проверять сознательно:**
|
||||
|
||||
- `autotests`: разбор вывода настоящего `ffprobe` — решение и цена в
|
||||
`ADR-2026-08-11-stub-adapters-in-tests.md`;
|
||||
- **работа сервиса с настоящими внешними собеседниками** в остатке: за настоящие
|
||||
SpeechKit и Object Storage живой прогон не отвечает — ключи выдуманные,
|
||||
распознавание подменяется в коде. Вход при этом с 2026-08-22 живой прогон
|
||||
проверяет целиком (`cmd/devtools proxy`), и это сдвиг в другую сторону.
|
||||
|
||||
Плюс общее, что не проверяет ни один прогон: история инцидентов, поведение под
|
||||
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
|
||||
текущее поведение и вопрос «а нужна ли эта функциональность вообще».
|
||||
|
||||
### Каких документов проекта не хватило
|
||||
|
||||
- `docs/review.md`, «Типовые ложноположительные» — **есть и не пуст**, отсев по
|
||||
нему выполнен. Одна находка (`code`, 19) отсеяна наполовину по общим
|
||||
критериям, а не по нему: разрешение клиентского текста для состояния «связи
|
||||
нет» стоит в `docs/conventions/web-ui.md`, а не в разделе
|
||||
ложноположительных.
|
||||
- `CLAUDE.md`, «Инварианты» — есть; ни одна находка этого прогона до `critical`
|
||||
по этому основанию не поднята, потому что ни одна инвариант не нарушает:
|
||||
проверено поимённо для приватности содержимого (значение заголовка в журнал и
|
||||
в ответ не уходит — замерено), для секрета в конфиге (секретов в секции
|
||||
`[auth]` больше нет) и для запрета переписывать применённый шаг схемы.
|
||||
- **Прочих пробелов в документах проходы не назвали** — но назвать их было
|
||||
некому: блоков границ у проходов нет (см. выше). Отсутствие строки здесь не
|
||||
означает, что документов хватило.
|
||||
|
||||
### Четыре строки, которых не принёс ни один проход
|
||||
|
||||
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
|
||||
его не открывает. Изменение заводит `ADR-2026-08-22-login-by-trusted-header.md`,
|
||||
и сверку его с остальными записанными решениями — включая те, что это
|
||||
изменение помечает (`ADR-2026-08-12-oidc-exchange-via-own-route`,
|
||||
`ADR-2026-08-12-session-without-refresh`), — конвейер ревью не делал.
|
||||
Расхождение изменения с записанным решением ловит скилл
|
||||
`av-dev:doc-healthcheck`, и звать его надо руками.
|
||||
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
|
||||
процессный. Всякое число в этом отчёте снято на этом прогоне: приоритеты
|
||||
слоёв взяты из исходников библиотеки в `GOMODCACHE`, счёт учётных записей и
|
||||
коды ответов — из прогонов, приложенных к находкам.
|
||||
3. **Поимённая сверка с руководствами по стилю Go и Vue не задавалась ни одним
|
||||
проходом.** Различение «идиоматично против распространено» на этом прогоне не
|
||||
спрашивал никто.
|
||||
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||
нет.** Проход независимой реализации снят по стоимости, а не по замеру.
|
||||
«Не знаю, чего не знаю» про схему узнавания по доверенному заголовку никто не
|
||||
доставал.
|
||||
|
||||
Пятой строки — про метку `small` — на этом прогоне нет: метка `large`, дома тем
|
||||
`security`, `operations` и `architecture` открывались.
|
||||
|
||||
### Что делал сам триаж
|
||||
|
||||
- Прочитаны: дифф изменения, `CLAUDE.md`, `docs/review.md` целиком,
|
||||
`docs/security.md`, `docs/architecture.md` «Эксплуатация», `docs/database.md`
|
||||
«Настройки с числовым значением», три конвенции, дельта-спека `access`,
|
||||
исходники `pocketbase@v0.39.10` (`apis/middlewares*.go`,
|
||||
`core/event_request.go`).
|
||||
- Запущено во временном файле проверок пакета `internal/controller/http`
|
||||
(удалён после прогона, дерево восстановлено, `go build ./...` и
|
||||
`go test ./internal/controller/http ./internal/adapter/repo/pocketbase`
|
||||
зелёные):
|
||||
- заведение учётных записей запросами, ответившими `429` (`2 → 52`);
|
||||
- общий бюджет ограничителя между двумя людьми;
|
||||
- `500` и `ERROR` на длинном `Remote-Name`;
|
||||
- пустой журнал на заведении учётной записи;
|
||||
- расход бюджета анонимными отказами до и после перестановки приоритетов
|
||||
(`200` → `429`).
|
||||
- Перестановка приоритетов на −999/−998 применена **временно** и откачена;
|
||||
на ней прогнан весь `go test ./...`. Код изменения не правился.
|
||||
@@ -0,0 +1,674 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Пришедшего называет доверенный источник
|
||||
|
||||
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
|
||||
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
|
||||
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
|
||||
не остаётся.
|
||||
|
||||
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
|
||||
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
|
||||
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
|
||||
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
|
||||
барьер вовсе.
|
||||
|
||||
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
|
||||
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
|
||||
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
|
||||
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
|
||||
требованием учётной записи на адресах приложения.
|
||||
|
||||
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает
|
||||
хранилище, а на новом имени ещё и пишет в него; выполненное раньше ограничителя,
|
||||
оно работало бы на запросах, которые тот уже отверг, и поток отвергнутых
|
||||
обращений заводил бы учётные записи, которые потом не убираются ничем.
|
||||
|
||||
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
|
||||
Область — корень приложения и адрес, которым хранилище выдаёт короткий токен
|
||||
файла; она MUST выводиться из объявленного адресного пространства сервиса, а не
|
||||
перечисляться вторым списком. Собственная поверхность хранилища под узнавание
|
||||
MUST не подпадать: правка учётной записи, её чтение и перечисление коллекции
|
||||
пользователей остаются недостижимыми для узнанного, потому что ключ учётной
|
||||
записи лежит там обычной колонкой, а правило правки у коллекции — библиотечное и
|
||||
разрешает править свою запись. Расширение области на всё дало бы узнанному
|
||||
переписать себе ключ на чужое имя и забрать чужой архив.
|
||||
|
||||
Сужение области закрывает и вторую вещь: узнавание MUST не срабатывать на пробе
|
||||
здоровья, на метриках и на ресурсах приложения. Иначе запрос за каждой картинкой
|
||||
стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё.
|
||||
|
||||
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
|
||||
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
|
||||
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
|
||||
без этой нормы все неназванные собрались бы в одну учётную запись с общим
|
||||
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
|
||||
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
|
||||
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
|
||||
бы вход анониму. Значение сверх объявленного предела длины и значение с
|
||||
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
|
||||
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
|
||||
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
|
||||
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
|
||||
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
|
||||
считает колонка.
|
||||
|
||||
Отказ хранилища при узнавании MUST кончаться отказом сервиса, а не молчаливым
|
||||
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
|
||||
|
||||
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
|
||||
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
|
||||
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
|
||||
настройке журнала: обе строки означают поломку контура, а поломка, записанная
|
||||
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
|
||||
которого никто не может войти, не отличит своей поломки (перечень доверенных
|
||||
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
|
||||
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
|
||||
нести значения заголовка.
|
||||
|
||||
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
|
||||
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
|
||||
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
|
||||
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
|
||||
|
||||
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
|
||||
MUST не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же:
|
||||
**короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по
|
||||
ссылке на файл записи; его нормирует capability `storage`, а срок его жизни
|
||||
назначается числом и живёт там, где проект держит числовые настройки. На этот
|
||||
срок — и только на него — отзыв доступа до файловой ссылки не доходит.
|
||||
|
||||
В остальном смысл именно таков: отзыв доступа судит провайдер на каждом
|
||||
обращении, а не однажды выданный срок.
|
||||
|
||||
Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок:
|
||||
владелец панели предъявляет свой, и подмена его учётной записью пользователя
|
||||
отобрала бы у него панель посреди работы.
|
||||
|
||||
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
|
||||
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
|
||||
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
|
||||
адресом его почты.
|
||||
|
||||
#### Scenario: Заголовок с доверенного адреса узнаёт человека
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
|
||||
- **THEN** запрос идёт от имени учётной записи с этим значением
|
||||
|
||||
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
|
||||
|
||||
- **GIVEN** адреса источника в перечне доверенных нет
|
||||
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи с этим значением не появляется
|
||||
|
||||
#### Scenario: Предъявленный токен побеждает заголовок
|
||||
|
||||
- **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен
|
||||
хранилища
|
||||
- **WHEN** сервис решает, кто пришёл
|
||||
- **THEN** пришедшим считается предъявитель токена
|
||||
|
||||
#### Scenario: Протухший токен узнаванию не мешает
|
||||
|
||||
- **GIVEN** запрос несёт заголовок `Remote-User` и негодный либо протухший токен
|
||||
хранилища
|
||||
- **WHEN** сервис решает, кто пришёл
|
||||
- **THEN** пришедшим считается названный заголовком
|
||||
|
||||
#### Scenario: Пустой заголовок не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Два значения одного заголовка не узнают никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Значение сверх предела длины не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Поверхность хранилища узнаванию не подпадает
|
||||
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** он правит запись коллекции пользователей собственным адресом
|
||||
хранилища
|
||||
- **THEN** правка не проходит
|
||||
|
||||
#### Scenario: Проба здоровья учётной записи не заводит
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
|
||||
- **THEN** учётной записи не появляется
|
||||
|
||||
#### Scenario: Недоверенный источник виден в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком приходит с недоверенного адреса
|
||||
- **THEN** журнал несёт строку об этом исходе с адресом пира
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
#### Scenario: Сервис не ставит браузеру куки
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения проходит с заголовком
|
||||
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
|
||||
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Учётная запись заводится первым обращением
|
||||
|
||||
Сервис SHALL заводить учётную запись при первом обращении с новым значением
|
||||
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
|
||||
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
|
||||
коллекции пользователей.
|
||||
|
||||
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
|
||||
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
|
||||
пределу колонки и чистится от управляющих знаков, негодный адрес почты
|
||||
отбрасывается. Негодное значение необязательного поля MUST не отменять
|
||||
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
|
||||
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе
|
||||
всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы
|
||||
карточку человека молча, посреди его работы.
|
||||
|
||||
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
|
||||
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
|
||||
с чужим адресом досталось бы чужой записи.
|
||||
|
||||
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
|
||||
Ни запросом снаружи, ни рукой в панели: переписанный ключ отдаёт архив
|
||||
следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи
|
||||
назначается один раз и не меняется. Правило доступа коллекции пользователей MUST
|
||||
закрывать правку записи снаружи наглухо, и MUST это держать схема, а не область
|
||||
действия узнавания: защита, стоящая на том, что до поверхности хранилища никто не
|
||||
дотянется, однажды уже оказалась случайной.
|
||||
|
||||
Одновременные первые обращения одним значением MUST кончаться одной учётной
|
||||
записью: уникальность держит схема, а не порядок обращений.
|
||||
|
||||
**Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой
|
||||
колонке — это гонка двух первых обращений одним именем, и он MUST кончаться
|
||||
повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес
|
||||
почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться
|
||||
заведением записи **без почты**: она необязательна. Без этого разреза второй
|
||||
человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск
|
||||
по имени снова ничего не находит.
|
||||
|
||||
Цена ключа называется целиком, обеими сторонами. Переименование пользователя у
|
||||
провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней;
|
||||
слить их или убрать нечем — владелец записи не меняется, а учётная запись с
|
||||
записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек,
|
||||
которому провайдер выдал логин ушедшего, при первом обращении попадает в
|
||||
существующую запись и получает весь её архив. Не допускать переиспользования —
|
||||
работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта
|
||||
цена принимается, а не обходится.
|
||||
|
||||
#### Scenario: Первое обращение заводит запись
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит запрос с заголовком `Remote-User`
|
||||
- **THEN** учётная запись появляется
|
||||
- **AND** запрос идёт от её имени
|
||||
|
||||
#### Scenario: Повторное обращение попадает в ту же запись
|
||||
|
||||
- **GIVEN** учётная запись заведена первым обращением
|
||||
- **WHEN** приходит второй запрос с тем же значением заголовка
|
||||
- **THEN** новой учётной записи не появляется
|
||||
- **AND** запрос идёт от имени прежней
|
||||
|
||||
#### Scenario: Разным значениям — разные записи
|
||||
|
||||
- **WHEN** приходят запросы с двумя разными значениями заголовка
|
||||
- **THEN** заводятся две учётные записи
|
||||
- **AND** записи одного не видны другому
|
||||
|
||||
#### Scenario: Имя не переписывается вторым обращением
|
||||
|
||||
- **GIVEN** учётная запись заведена с одним значением `Remote-Name`
|
||||
- **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name`
|
||||
- **THEN** имя учётной записи остаётся прежним
|
||||
|
||||
#### Scenario: Два одновременных первых обращения дают одну запись
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** два запроса с одним значением заголовка приходят одновременно
|
||||
- **THEN** в коллекции пользователей появляется ровно одна запись
|
||||
- **AND** оба запроса идут от её имени
|
||||
|
||||
#### Scenario: Занятая почта не мешает завести запись
|
||||
|
||||
- **GIVEN** учётная запись с этим адресом почты уже заведена
|
||||
- **WHEN** приходит первое обращение с другим `Remote-User` и тем же
|
||||
`Remote-Email`
|
||||
- **THEN** заводится своя учётная запись
|
||||
- **AND** адреса почты у неё нет
|
||||
|
||||
#### Scenario: Ключ учётной записи не правится и рукой в панели
|
||||
|
||||
- **GIVEN** учётная запись заведена
|
||||
- **WHEN** её ключ меняют сохранением записи мимо адресов приложения
|
||||
- **THEN** сохранение отвергается, а ключ остаётся прежним
|
||||
|
||||
#### Scenario: Негодное имя не отменяет заведения
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит обращение с именем длиннее предела колонки
|
||||
- **THEN** учётная запись заводится, а имя обрезано по пределу
|
||||
|
||||
#### Scenario: Негодная почта отбрасывается, а не отменяет заведение
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит обращение с адресом почты, не похожим на адрес
|
||||
- **THEN** учётная запись заводится без почты
|
||||
|
||||
#### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит
|
||||
|
||||
- **GIVEN** бюджет ограничителя частоты выбран
|
||||
- **WHEN** приходит обращение с новым значением заголовка
|
||||
- **THEN** ответ несёт отказ ограничителя
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Заведение учётной записи видно в журнале
|
||||
|
||||
- **WHEN** приходит первое обращение с новым значением заголовка
|
||||
- **THEN** журнал несёт строку о заведении с идентификатором записи
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
#### Scenario: Ключ учётной записи снаружи не правится
|
||||
|
||||
- **GIVEN** человек узнан и его учётная запись заведена
|
||||
- **WHEN** он правит ключ своей учётной записи запросом к хранилищу
|
||||
- **THEN** правка не проходит, а ключ остаётся прежним
|
||||
|
||||
### Requirement: Доверенный источник объявлен настройкой
|
||||
|
||||
Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт,
|
||||
когда перечень пуст либо его строки не читаются как адрес или подсеть. Пустой
|
||||
перечень значит «не верить никому»: сервис поднялся бы никого не узнающим, а
|
||||
узнать об этом было бы неоткуда.
|
||||
|
||||
Отказ старта MUST называть имя ключа. Ни адресов провайдера, ни идентификатора
|
||||
клиента, ни секрета клиента в конфиге MUST не быть: менять код больше не на что,
|
||||
и секрет уходит из конфига вместе с протоколом.
|
||||
|
||||
Перечень MUST называться строкой журнала при подъёме. Сервис, никого не узнающий
|
||||
из-за неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
||||
доходит вовсе, — а это разные поломки в разных местах.
|
||||
|
||||
#### Scenario: Пустой перечень роняет старт
|
||||
|
||||
- **WHEN** сервис поднимается с пустым перечнем доверенных адресов
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** отказ называет имя ключа
|
||||
|
||||
#### Scenario: Негодная строка перечня роняет старт
|
||||
|
||||
- **WHEN** сервис поднимается с перечнем, где строка не читается как адрес или
|
||||
подсеть
|
||||
- **THEN** старт кончается отказом
|
||||
|
||||
#### Scenario: Перечень виден в журнале подъёма
|
||||
|
||||
- **WHEN** сервис поднимается с заполненным перечнем
|
||||
- **THEN** журнал подъёма называет доверенные адреса
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Иных способов открыть сессию нет
|
||||
|
||||
Сервис SHALL оставить собственные адреса входа хранилища неработающими: ни один
|
||||
из них MUST не давать доступа и MUST не менять учётной записи. Собственное
|
||||
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
|
||||
коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть
|
||||
выключены настройкой коллекции.
|
||||
|
||||
**Закрывается не только вход, но и правка.** Перечисление, чтение, создание,
|
||||
правка и удаление записи коллекции пользователей MUST быть закрыты правилами
|
||||
доступа — то есть оставлены пустыми, что у хранилища означает «только владелец
|
||||
панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих
|
||||
пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с
|
||||
кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть
|
||||
защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей
|
||||
записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил,
|
||||
панель работает суперпользователем, своих экранов профиля сервис не заводит —
|
||||
закрытие не стоит ничего.
|
||||
|
||||
Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то
|
||||
нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
|
||||
хранилища заводит коллекцию пользователей с открытым созданием записи и
|
||||
включённым входом по паролю, и без этого требования узнавание по заголовку
|
||||
обходится двумя запросами: завести себе запись, войти по паролю, предъявить
|
||||
полученное.
|
||||
|
||||
Отдельная цена у открытого создания записи — захват учётной записи: запись,
|
||||
заведённая посторонним под чужим именем, досталась бы первому же настоящему
|
||||
обращению с этим именем.
|
||||
|
||||
Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при
|
||||
первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему
|
||||
не судья.
|
||||
|
||||
#### Scenario: Завести учётную запись самому нельзя
|
||||
|
||||
- **WHEN** запрос снаружи создаёт запись в коллекции пользователей
|
||||
- **THEN** ответ несёт отказ, а записи не появляется
|
||||
|
||||
#### Scenario: Обращение с заголовком запись заводит
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** запрос с заголовком приходит с доверенного адреса
|
||||
- **THEN** учётная запись появляется
|
||||
|
||||
#### Scenario: Вход паролем недоступен
|
||||
|
||||
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
|
||||
- **THEN** ответ несёт отказ, а доступа не открывается
|
||||
|
||||
#### Scenario: Обмен кода у провайдера недоступен
|
||||
|
||||
- **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции
|
||||
пользователей
|
||||
- **THEN** ответ несёт отказ, а доступа не открывается
|
||||
|
||||
#### Scenario: Восстановление доступа недоступно
|
||||
|
||||
- **WHEN** запрос просит восстановление пароля или одноразовый код
|
||||
- **THEN** ответ несёт отказ
|
||||
|
||||
#### Scenario: Правка учётной записи снаружи закрыта
|
||||
|
||||
- **GIVEN** человек узнан и его учётная запись заведена
|
||||
- **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу
|
||||
- **THEN** ответ несёт отказ, а запись остаётся прежней
|
||||
|
||||
#### Scenario: Перечисление учётных записей закрыто
|
||||
|
||||
- **GIVEN** человек узнан
|
||||
- **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу
|
||||
- **THEN** ответ несёт отказ
|
||||
|
||||
### Requirement: Значение, дающее доступ, не печатается
|
||||
|
||||
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
|
||||
которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя.
|
||||
|
||||
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
|
||||
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
|
||||
не убрать. Требование того же рода, что и запрет писать имя файла в хранилище:
|
||||
там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым
|
||||
довольно назваться, чтобы стать этим человеком.
|
||||
|
||||
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
|
||||
заголовка — тоже: это логин человека у провайдера.
|
||||
|
||||
Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом,
|
||||
доступа сам по себе не даёт и без него путь запроса не прослеживается.
|
||||
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Адреса почты нет в журнале
|
||||
|
||||
- **WHEN** приходит первое обращение и учётная запись заводится
|
||||
- **THEN** адрес почты не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Кого пускать, решает провайдер
|
||||
|
||||
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
|
||||
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
|
||||
сервиса — настройка выкладки, лежащая вне репозитория.
|
||||
|
||||
Требование записано именно как решение с ценой, а не как умолчание: провайдер
|
||||
общий для контура, и правило, настроенное слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
|
||||
поэтому граница названа здесь и повторена в модели угроз.
|
||||
|
||||
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
|
||||
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
|
||||
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
|
||||
жизни этого значения. Теперь отзыв действует со следующего запроса.
|
||||
|
||||
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
|
||||
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
|
||||
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
|
||||
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
|
||||
|
||||
#### Scenario: Названный провайдером получает доступ
|
||||
|
||||
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
|
||||
прокси
|
||||
- **THEN** доступ открывается, а учётная запись заводится, если её не было
|
||||
- **AND** сервис не спрашивает у заголовков ничего сверх имени, показного имени
|
||||
и адреса почты
|
||||
|
||||
#### Scenario: Отзыв у провайдера действует со следующего запроса
|
||||
|
||||
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
|
||||
- **WHEN** приходит следующий его запрос
|
||||
- **THEN** прокси заголовка не ставит, и запрос получает отказ
|
||||
|
||||
### Requirement: Проба здоровья и метрики остаются открытыми
|
||||
|
||||
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
|
||||
здоровья, ни у сборщика метрик учётной записи нет, и требование узнавания
|
||||
остановило бы наблюдение за сервисом.
|
||||
|
||||
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
|
||||
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
|
||||
адреса не несут.
|
||||
|
||||
Заголовок, пришедший с недоверенного адреса, MUST не менять их ответа: узнавание
|
||||
отказа не выдаёт, и посторонняя строка в запросе не вправе гасить наблюдение.
|
||||
|
||||
#### Scenario: Проба здоровья доступна неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на `GET /health` без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Метрики доступны неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на `GET /metrics` без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Чужой заголовок наблюдения не гасит
|
||||
|
||||
- **WHEN** запрос на `GET /health` приходит с недоверенного адреса с заголовком
|
||||
`Remote-User`
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||
|
||||
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
|
||||
имени которой запись принята, — и MUST отдавать данные такой записи только её
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
|
||||
конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и
|
||||
норму эту держит capability `storage`.
|
||||
|
||||
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
|
||||
доступа, ролей и передачи записи другому сервис не знает.
|
||||
|
||||
Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
|
||||
имя.
|
||||
|
||||
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
|
||||
Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
|
||||
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
|
||||
что разграничение прячет. Каким именно ответом это выражено, нормирует
|
||||
capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
|
||||
быть один.
|
||||
|
||||
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
|
||||
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
|
||||
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
|
||||
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
|
||||
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
|
||||
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
|
||||
|
||||
#### Scenario: Своя запись доступна
|
||||
|
||||
- **GIVEN** человек узнан и принял запись
|
||||
- **WHEN** он спрашивает карточку этой записи
|
||||
- **THEN** ответ несёт данные записи
|
||||
|
||||
#### Scenario: Чужая запись неотличима от несуществующей
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** её карточку спрашивает другой узнанный
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Владельца не задают запросом
|
||||
|
||||
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||
- **THEN** владельцем принятой записи становится узнанный предъявитель
|
||||
|
||||
#### Scenario: Ничью запись завести нечем
|
||||
|
||||
- **WHEN** запись пытаются завести с пустым владельцем
|
||||
- **THEN** хранилище её не сохраняет
|
||||
|
||||
#### Scenario: Пустой владелец не открывает ничего
|
||||
|
||||
- **GIVEN** заведены две записи: своя и чужая
|
||||
- **WHEN** карточку каждой спрашивают с пустым владельцем
|
||||
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
|
||||
|
||||
### Requirement: Приложение узнаёт вошедшего
|
||||
|
||||
Сервис SHALL отдавать приложению сведения о том, кто пришёл, — `GET /app/me` — и
|
||||
MUST отвечать отказом `401`, когда пришедший не узнан. Своей страницы со
|
||||
скриптом, которой сервер отрисовал бы имя пришедшего, у сервиса нет: приложение
|
||||
собирает разметку само и пришедшего узнаёт ответом.
|
||||
|
||||
Заголовок ставит прокси, и прочитать его из браузера приложение не может вовсе —
|
||||
этот адрес единственный способ узнать, кто пришёл.
|
||||
|
||||
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
|
||||
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
|
||||
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
|
||||
ему выходить наружу наравне с журналом.
|
||||
|
||||
#### Scenario: Пришедший узнан
|
||||
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** приложение спрашивает, кто пришёл
|
||||
- **THEN** ответ несёт идентификатор его учётной записи
|
||||
|
||||
#### Scenario: Пришедший не узнан
|
||||
|
||||
- **WHEN** приложение спрашивает, кто пришёл, без заголовка
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа не несёт учётной записи
|
||||
|
||||
#### Scenario: Адреса почты в ответе нет
|
||||
|
||||
- **GIVEN** пришедший узнан, и у его учётной записи есть адрес почты
|
||||
- **WHEN** приложение спрашивает, кто пришёл
|
||||
- **THEN** адреса почты в ответе нет
|
||||
|
||||
### Requirement: Приложение отдаётся без сессии
|
||||
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы неузнанному. Перечень
|
||||
адресов, открытых без узнавания, пополняется ими: прежде в нём стояли только
|
||||
проба здоровья и метрики.
|
||||
|
||||
Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал,
|
||||
до приложения не доходит вовсе. Разметка при этом обязана отдаваться и ему —
|
||||
иначе неудача узнавания выглядела бы поломкой сервиса, а не отказом входа. Цена
|
||||
открытости названа здесь же и невелика: ни разметка, ни ресурсы содержимого
|
||||
записей не несут — они одинаковы для всех и собираются до всякого запроса.
|
||||
|
||||
Открытость MUST не касаться данных: всякий адрес под корнем приложения
|
||||
по-прежнему требует узнанного предъявителя, и приложение, открытое неузнанным,
|
||||
не получает ни одной записи.
|
||||
|
||||
#### Scenario: Разметка доступна неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на корень сервиса без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Ресурс приложения доступен неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на ресурс приложения без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Данные неузнанному не отдаются
|
||||
|
||||
- **GIVEN** приложение открыто без заголовка
|
||||
- **WHEN** оно спрашивает список записей
|
||||
- **THEN** ответ имеет код `401`
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Вход через внешнего провайдера
|
||||
|
||||
**Reason**: Сервис больше не ведёт входа. Собственный адрес, уводящий к
|
||||
провайдеру, адрес возврата, сверка состояния, проверочный код PKCE и обмен кода
|
||||
средствами хранилища убраны целиком: кто пришёл, называет обратный прокси,
|
||||
сходивший к провайдеру на **каждом** запросе.
|
||||
|
||||
**Migration**: Узнавание нормирует требование «Пришедшего называет доверенный
|
||||
источник». Правило провайдера переезжает с клиента OIDC на домен сервиса и живёт
|
||||
в настройках выкладки.
|
||||
|
||||
### Requirement: Сессия предъявляется кукой
|
||||
|
||||
**Reason**: Сессии у сервиса нет вовсе. Кука `transcriber_session`, кука
|
||||
состояния входа `transcriber_login` и слой, перекладывающий значение куки в
|
||||
заголовок хранилища, убраны: узнавание идёт заголовком на каждом запросе, и
|
||||
значения, переживающего запрос, сервис не выдаёт.
|
||||
|
||||
**Migration**: Требование «Пришедшего называет доверенный источник» — там же
|
||||
записан запрет выдавать браузеру что-либо, переживающее запрос.
|
||||
|
||||
### Requirement: Сессия переживает перезапуск сервиса
|
||||
|
||||
**Reason**: Переживать перезапуск нечему. Узнавание не опирается ни на значение,
|
||||
выданное однажды, ни на секрет подписи: заголовок приходит с каждым запросом.
|
||||
|
||||
**Migration**: Не требуется — свойство выполняется по построению.
|
||||
|
||||
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
|
||||
|
||||
**Reason**: Срока нет, потому что нет самой сессии. Он существовал ровно затем,
|
||||
чтобы отзыв доступа у провайдера когда-нибудь дошёл до сервиса; теперь провайдер
|
||||
судит каждый запрос, и отзыв действует со следующего. Запрет продления и способ
|
||||
закрыть чужие сессии немедленно теряют предмет вместе со сроком.
|
||||
|
||||
**Migration**: Отзыв доступа нормирует требование «Кого пускать, решает
|
||||
провайдер», сценарий про следующий запрос.
|
||||
|
||||
### Requirement: Выход прекращает доступ
|
||||
|
||||
**Reason**: Выхода у сервиса нет: обесценивать нечего и куку убирать неоткуда.
|
||||
Выходят у провайдера, и со следующего запроса прокси заголовка уже не поставит.
|
||||
|
||||
**Migration**: Требование «Кого пускать, решает провайдер».
|
||||
|
||||
### Requirement: Секрет провайдера живёт в конфиге
|
||||
|
||||
**Reason**: Секрета клиента у сервиса больше нет: обменивать код не на что.
|
||||
Вместе с ним из конфига уходят адреса провайдера и идентификатор клиента, а из
|
||||
настроек коллекции пользователей — приведение их к конфигу при подъёме. Отсюда же
|
||||
снимается изъятие из инварианта «Секрет не покидает конфиг»: чтение файла базы
|
||||
больше не равносильно чтению секрета клиента.
|
||||
|
||||
**Migration**: Настройку узнавания нормирует требование «Доверенный источник
|
||||
объявлен настройкой»; проверка целостности настройки на старте сохраняется там.
|
||||
@@ -0,0 +1,100 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Отказ называет причину, а не место
|
||||
|
||||
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
|
||||
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
|
||||
|
||||
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
|
||||
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
|
||||
разнице кодов перебирается список заведённых записей;
|
||||
- узнанный предъявитель без учётной записи пользователя — `403`;
|
||||
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
|
||||
отвечать чужая и ничья запись;
|
||||
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
|
||||
негодный размер страницы;
|
||||
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
|
||||
- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у
|
||||
записи ещё нет;
|
||||
- отказ хранилища и всякая неназванная причина — `500`.
|
||||
|
||||
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
|
||||
адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места
|
||||
нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую
|
||||
базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как
|
||||
«моя запись пропала», а второе не говорит ему ничего.
|
||||
|
||||
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
|
||||
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
|
||||
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
|
||||
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
|
||||
все три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
|
||||
задача экрана переписала бы контракт, согласованный здесь один раз.
|
||||
|
||||
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и
|
||||
выбранные кодом они стали бы контрактом молча:
|
||||
|
||||
- поля тела: `error_code` и `message`;
|
||||
- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`,
|
||||
`bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`.
|
||||
|
||||
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
|
||||
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
|
||||
доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на
|
||||
адресах приложения две, а самый частый отказ у человека на мобильной сети —
|
||||
«запись больше потолка» — приходит телом библиотеки, без кода и без предела
|
||||
числом.
|
||||
|
||||
Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа
|
||||
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
|
||||
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
|
||||
журнале аварию там, где её нет.
|
||||
|
||||
Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали
|
||||
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
|
||||
остаётся в журнале владельца сервиса.
|
||||
|
||||
#### Scenario: Сбой хранилища виден как сбой
|
||||
|
||||
- **GIVEN** хранилище отвечает отказом драйвера на чтение записи
|
||||
- **WHEN** владелец спрашивает свою запись
|
||||
- **THEN** ответ имеет код `500`
|
||||
- **AND** тела записи в ответе нет
|
||||
|
||||
#### Scenario: Негодная запись видна как негодная
|
||||
|
||||
- **GIVEN** источник метаданных не может прочитать присланную запись
|
||||
- **WHEN** отправитель шлёт её приёмом
|
||||
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
|
||||
- **AND** причина отказа в тело ответа не попадает
|
||||
|
||||
#### Scenario: Отказ по пустому владельцу
|
||||
|
||||
- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет
|
||||
- **WHEN** он шлёт запись приёмом
|
||||
- **THEN** ответ имеет код `403`
|
||||
|
||||
#### Scenario: Форма тела одна на всех ветвях отказа
|
||||
|
||||
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
|
||||
отсутствию учётной записи и по сбою хранилища
|
||||
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
|
||||
полями
|
||||
- **AND** код отказа принадлежит закрытому перечню
|
||||
- **AND** ни одно из них не содержит сырого текста ошибки
|
||||
|
||||
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
|
||||
|
||||
- **GIVEN** заведена запись
|
||||
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
|
||||
неизвестному идентификатору
|
||||
- **THEN** оба ответа имеют код `401` и одно тело
|
||||
|
||||
#### Scenario: Запись сверх потолка размера
|
||||
|
||||
- **GIVEN** отправитель узнан
|
||||
- **WHEN** он шлёт запись длиннее потолка размера
|
||||
- **THEN** ответ имеет код `413`, а тело несёт предел числом
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
|
||||
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||
Запрос от неузнанного MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
|
||||
получить заведённую под неё аудиозапись на рубеже `uploaded`.
|
||||
|
||||
Приём стоит тем же адресом, что и список записей, и отличается от него только
|
||||
методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло
|
||||
содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя
|
||||
запись он и создаёт.
|
||||
|
||||
Ответ MUST нести **список** заведённых записей и место под признак повторного
|
||||
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
|
||||
вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом
|
||||
нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки —
|
||||
переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним:
|
||||
меняется форма ответа, не число файлов.
|
||||
|
||||
Элемент списка MUST нести те же поля, что и карточка записи, плюс признак
|
||||
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
|
||||
Состав карточки нормирует capability `archive`.
|
||||
|
||||
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес
|
||||
опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка
|
||||
публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней
|
||||
программы на прежнем контракте не существует — своего токена у неё не было.
|
||||
|
||||
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
|
||||
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
|
||||
перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет
|
||||
достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе.
|
||||
|
||||
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
|
||||
и код с телом такого отказа нормирует capability `archive` наравне с прочими
|
||||
ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание —
|
||||
самый частый отказ у человека на мобильной сети — прежде не был нормирован
|
||||
ничем и уходил телом ограничителя тела, мимо единой формы.
|
||||
|
||||
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
|
||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
пригодность содержимого узнаёт у источника метаданных.
|
||||
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность
|
||||
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
|
||||
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
|
||||
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
|
||||
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
|
||||
файла.
|
||||
|
||||
Предъявитель, узнанный без учётной записи пользователя, MUST получать
|
||||
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ
|
||||
неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно
|
||||
такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него
|
||||
нет, и владельцем записи он стать не может.
|
||||
|
||||
Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит
|
||||
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
|
||||
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
|
||||
|
||||
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
|
||||
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
|
||||
пишется.
|
||||
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель узнан
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
|
||||
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
|
||||
место под признак повторного файла
|
||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
|
||||
|
||||
#### Scenario: Узнанный без учётной записи пользователя
|
||||
|
||||
- **GIVEN** предъявлен собственный токен владельца панели
|
||||
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
|
||||
- **THEN** ответ имеет код `403`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Пришедший не узнан
|
||||
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
- **AND** тело ответа не несёт данных записи
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
- **GIVEN** отправитель узнан
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
|
||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Размеру записи приём не судья
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель узнан
|
||||
- **WHEN** программа шлёт запись нулевой длины
|
||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Файл отдаётся ссылкой
|
||||
|
||||
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||||
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||||
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||||
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||||
|
||||
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
|
||||
Правило MUST пускать только владельца файла: незаданное означает «только владелец
|
||||
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
|
||||
отдавало чужое аудио тому, кто знает идентификатор записи.
|
||||
|
||||
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
|
||||
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
|
||||
токена: отказ наступает там, и требовать его от выдачи значит требовать
|
||||
механизма, которого нет.
|
||||
|
||||
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
|
||||
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
|
||||
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
|
||||
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
|
||||
хранилища, а не недосмотр.
|
||||
|
||||
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||||
хранилища, а не по ссылке.
|
||||
|
||||
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||||
|
||||
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
|
||||
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
|
||||
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
|
||||
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||||
бессрочно.
|
||||
|
||||
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||||
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
|
||||
половину ключа.
|
||||
|
||||
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||||
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||||
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||||
другое кончается в журнале и собирает ссылку не хуже успешного пути.
|
||||
|
||||
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
|
||||
`intake`.
|
||||
|
||||
#### Scenario: Файл забирают по ссылке
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **AND** забирающий узнан и взял токен файла
|
||||
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||
|
||||
#### Scenario: Неузнанному файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **WHEN** ссылку на файл запрашивают неузнанным
|
||||
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||
|
||||
#### Scenario: Токен файла выдаётся узнанному по заголовку
|
||||
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** он просит у хранилища токен файла
|
||||
- **THEN** токен выдаётся
|
||||
|
||||
#### Scenario: Конвейер читает файл без узнавания
|
||||
|
||||
- **GIVEN** запись принята и ждёт расшифровки
|
||||
- **WHEN** шаг конвейера берётся за неё
|
||||
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||||
|
||||
#### Scenario: Ссылка ведёт в никуда
|
||||
|
||||
- **WHEN** запрашивают ссылку на запись, которой нет
|
||||
- **THEN** приходит отказ, а не пустой ответ
|
||||
|
||||
#### Scenario: По журналу ссылку не собрать
|
||||
|
||||
- **GIVEN** запись принята и прошла конвейер
|
||||
- **WHEN** читают журнал сервиса целиком
|
||||
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
|
||||
|
||||
#### Scenario: Отказ чтения файла не называет его ключ
|
||||
|
||||
- **GIVEN** файл записи не читается из хранилища
|
||||
- **WHEN** шаг конвейера берётся за эту запись и отказывает
|
||||
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
|
||||
|
||||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||
|
||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||||
|
||||
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
|
||||
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
|
||||
значение оставалось у файлов, заведённых конвейером для записи без владельца;
|
||||
таких записей больше не заводится, и разное правило у записи и у её файла
|
||||
читалось бы как недосмотр.
|
||||
|
||||
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
|
||||
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
|
||||
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
|
||||
остановится признаком на первом же приведении.
|
||||
|
||||
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
|
||||
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
|
||||
выводиться через запись: файл переживает свою запись, и заведённый шагом до
|
||||
сохранения записи он остаётся с владельцем и без ссылки.
|
||||
|
||||
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||||
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||||
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||||
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||||
касаясь пути, по которому аудио и уходит.
|
||||
|
||||
#### Scenario: Чужой файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
|
||||
- **THEN** содержимого он не получает
|
||||
|
||||
#### Scenario: Свой файл отдаётся
|
||||
|
||||
- **GIVEN** человек принял запись
|
||||
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||||
- **THEN** содержимое отдаётся
|
||||
|
||||
#### Scenario: Файл без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** файл записи пытаются сохранить с пустым владельцем
|
||||
- **THEN** хранилище его не сохраняет
|
||||
|
||||
#### Scenario: Приведённая копия получает владельца записи
|
||||
|
||||
- **GIVEN** запись с владельцем дошла до приведения
|
||||
- **WHEN** шаг заводит приведённую копию файла
|
||||
- **THEN** владельцем копии стоит владелец записи
|
||||
- **AND** шаг завершается без отказа
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Неизвестный путь вне корней открывает приложение
|
||||
|
||||
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
|
||||
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
|
||||
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, —
|
||||
отдельными адресами стоят `/health` и `/metrics`.
|
||||
|
||||
Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и
|
||||
адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем
|
||||
же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя
|
||||
за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается
|
||||
от любого другого свободного имени, а второй перечень «когда-то занятых корней»
|
||||
разошёлся бы с первым молча.
|
||||
|
||||
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
|
||||
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
|
||||
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
|
||||
`/api` не достался бы никому и уехал бы разметкой.
|
||||
|
||||
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
|
||||
контракта остаётся отказом контракта и уходит той формой, которой этот корень
|
||||
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
|
||||
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
|
||||
приняла бы её за ответ.
|
||||
|
||||
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
|
||||
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
|
||||
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
|
||||
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
|
||||
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
|
||||
|
||||
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
|
||||
отвечать `405`.
|
||||
|
||||
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
|
||||
|
||||
- **GIVEN** приложение открыто на своём маршруте
|
||||
- **WHEN** браузер спрашивает этот путь заново
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Голый корень разметкой не подменяется
|
||||
|
||||
- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без
|
||||
косой черты
|
||||
- **THEN** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
|
||||
отделён от него косой чертой
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Прежний адрес входа открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на путь под прежним корнем входа
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
|
||||
|
||||
- **GIVEN** запрос идёт с заголовком, поставленным прокси
|
||||
- **WHEN** он спрашивает неизвестный путь под корнем приложения
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
|
||||
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
|
||||
заголовка
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
|
||||
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
|
||||
- **THEN** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
|
||||
|
||||
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
### Requirement: Открытое приложение показывает вошедшего
|
||||
|
||||
Приложение SHALL спрашивать сервис, кто пришёл, и показывать его имя. Отказ
|
||||
`401` MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
|
||||
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
|
||||
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
|
||||
только мимо него.
|
||||
|
||||
Всякий **иной** отказ и сорванный запрос MUST показываться строкой о неудаче.
|
||||
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
|
||||
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
|
||||
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
|
||||
перехода.
|
||||
|
||||
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
|
||||
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
|
||||
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
|
||||
адрес почты: его в ответе нет по норме `access`.
|
||||
|
||||
#### Scenario: Узнанный виден
|
||||
|
||||
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение показывает его имя
|
||||
|
||||
#### Scenario: Неузнанному показывают, что его не узнали
|
||||
|
||||
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
|
||||
- **WHEN** человек открывает приложение
|
||||
- **THEN** приложение показывает строку о том, что его не узнали
|
||||
- **AND** никуда его не уводит
|
||||
|
||||
#### Scenario: Отказ сервиса от неузнавания отличается
|
||||
|
||||
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
|
||||
неузнаванием
|
||||
- **WHEN** человек открывает приложение
|
||||
- **THEN** приложение показывает строку о неудаче
|
||||
- **AND** эта строка не та, которой оно сообщает о неузнавании
|
||||
@@ -0,0 +1,206 @@
|
||||
## Критерии приёмки
|
||||
|
||||
Дословно из записи задачи `trusted-header-login`. Файл задачи закрытие удалит —
|
||||
критерии обязаны его пережить.
|
||||
|
||||
1. Обращение к адресу приложения с заголовком от доверенного источника идёт от
|
||||
имени учётной записи, заведённой при первом таком обращении, а повторное с тем
|
||||
же значением попадает в ту же запись. Оракул — тест обработчика: два запроса
|
||||
подряд, в хранилище одна запись пользователя.
|
||||
2. Тот же заголовок с недоверенного адреса даёт `401`, а не вход под названным
|
||||
именем. Оракул — тест: запрос с адресом источника вне перечня доверенных.
|
||||
3. Собственные адреса входа хранилища сессии не выдают и учётную запись не
|
||||
меняют. Оракул — тест по перечню адресов под `/api/collections/users/`: каждый
|
||||
отвечает отказом.
|
||||
4. Механики OIDC в дереве не осталось: корня `/auth`, кук входа,
|
||||
`ApplyProviderSettings`, `cmd/oidcstub` и секрета клиента в конфиге. Оракул —
|
||||
поиск по этим именам плюс зелёный `task gate`.
|
||||
5. Разграничение записей по владельцу работает как прежде: чужая запись
|
||||
неотличима от несуществующей. Оракул — существующие тесты владельца остаются
|
||||
зелёными.
|
||||
|
||||
## Рубрика ревью дизайна
|
||||
|
||||
Двенадцать свойств, по которым судится узел этого рода — слой узнавания
|
||||
предъявителя плюс заведение учётной записи первым обращением. Порождены до
|
||||
чтения дизайна. Приёмка идёт по одному списку: эти пункты наравне с критериями
|
||||
постановки выше.
|
||||
|
||||
1. Доверие ограничено источником, и источник берётся у соединения, а не у
|
||||
пересылаемого значения; настройка проверяется на старте, небезопасного
|
||||
умолчания нет.
|
||||
2. Заголовок узнаётся однозначно: более одного значения не даёт «первое
|
||||
попавшееся».
|
||||
3. Вырожденное значение — пустое, пробельное, сверх предела длины, с
|
||||
управляющими знаками — не узнаёт никого и не заводит ничего.
|
||||
4. Ключ учётной записи стабилен, и цена его нестабильности названа в обе стороны
|
||||
— и смена, и переиспользование. Выборка по ключу имеет свой индекс.
|
||||
5. Заведение первым обращением идемпотентно и устойчиво к конкуренции; отказ
|
||||
уникальности не по ключевой колонке имеет назначенный исход.
|
||||
6. Результат остаётся функцией уже произошедшего: ключевая колонка неизменяема
|
||||
после заведения.
|
||||
7. Второго способа стать этим человеком нет, а при двух предъявленных удостоверениях
|
||||
победитель назначен нормой, а не порядком слоёв.
|
||||
8. Закрытие поверхности не отменяет законного пути заведения записи самим
|
||||
сервисом.
|
||||
9. Значение заголовка — недоверенный вход на всём пути: не в журнал, не в ответ,
|
||||
не в метку метрики, и в хранилище параметром, а не подстановкой в фильтр.
|
||||
10. Охват слоя назван, перечень открытых адресов закрыт, посторонний заголовок
|
||||
ответа открытых адресов не меняет.
|
||||
11. Срок узнавания назван: всё, что переживает запрос, либо отсутствует, либо
|
||||
имеет назначенный срок, и этот срок и есть задержка отзыва.
|
||||
12. Исход узнавания наблюдаем владельцем и неразличим для отправителя.
|
||||
|
||||
## 1. Схема хранилища
|
||||
|
||||
- [x] 1.1 Новый шаг схемы `202608220001_trusted_header_login.go`: колонка
|
||||
`provider_login` в коллекции `users` с уникальным индексом, почта
|
||||
переведена в необязательную, `OAuth2.Enabled = false` и
|
||||
`OAuth2.Providers = nil`. Применённый `202608120001_oidc_login` не
|
||||
трогается.
|
||||
- [x] 1.2 Тот же шаг снимает **все** правила доступа коллекции `users` в пустое —
|
||||
`ListRule`, `ViewRule`, `CreateRule`, `UpdateRule`, `DeleteRule`. Сегодня
|
||||
четыре из них библиотечные (`id = @request.auth.id`), и правка своей записи
|
||||
открыта: это путь захвата чужого имени через ключевую колонку.
|
||||
- [x] 1.3 Шаг зарегистрирован в `migrations.go`, имя ключевой колонки объявлено
|
||||
константой рядом с именами коллекций.
|
||||
- [x] 1.4 `schema_test.go` сторожит `users` наравне с шестью коллекциями,
|
||||
которые он уже проверяет: все пять правил пусты.
|
||||
- [x] 1.5 Откат шага возвращает то, что было до него, кроме открытого создания
|
||||
записи и открытой правки, и не падает на проверке коллекции.
|
||||
- [x] 1.6 `docs/database.md` описывает новую колонку, снятые настройки и снятые
|
||||
правила доступа.
|
||||
|
||||
## 2. Настройка
|
||||
|
||||
- [x] 2.1 Секция входа конфига переписана: адреса провайдера, идентификатор и
|
||||
секрет клиента, адрес возврата и признак защищённой куки убраны, перечень
|
||||
доверенных адресов заведён: секция `[auth]` остаётся под своим именем, в
|
||||
ней один ключ `trusted_proxies` (решение человека 2026-08-22).
|
||||
- [x] 2.2 Проверка настройки на старте: пустой перечень и нечитаемая строка
|
||||
роняют старт с именем ключа.
|
||||
- [x] 2.3 `config.example.toml` переписан вместе с секцией, раздел про локальный
|
||||
вход заменён на подставной прокси.
|
||||
|
||||
## 3. Узнавание пришедшего
|
||||
|
||||
- [x] 3.1 Слой узнавания в транспорте HTTP: читает заголовок, судит адрес пира по
|
||||
перечню, ставит учётную запись предъявителя только когда её ещё нет.
|
||||
Вешается корневым, но действует на объявленной области — корень приложения
|
||||
плюс адрес выдачи файлового токена; область выводится из перечня адресного
|
||||
пространства, а не пишется вторым списком.
|
||||
- [x] 3.2 Приём значения заголовка: пустое и пробельное не узнают никого, более
|
||||
одного значения не узнаёт никого, предел длины и отказ на управляющие
|
||||
знаки, сравнение точное. Значение уходит в хранилище параметром, а не
|
||||
подстановкой в текст фильтра.
|
||||
- [x] 3.3 Поиск и заведение учётной записи — **методом пакета хранилища**, а не
|
||||
куском в транспорте: у правила один дом, и `api-tokens` возьмёт его же.
|
||||
Имя и почта берутся только при заведении, найденная запись не
|
||||
переписывается; отказ уникальности по ключевой колонке ведёт к повторному
|
||||
поиску, по любой другой — к заведению записи без почты.
|
||||
- [x] 3.4 Отказ хранилища при узнавании кончается отказом сервиса, а не
|
||||
молчаливым проходом неузнанным.
|
||||
- [x] 3.5 Журнал: исходы «заголовок с недоверенного адреса» и «учётная запись
|
||||
заведена» — с адресом пира и идентификатором записи, без значения
|
||||
заголовка; перечень доверенных адресов называется строкой при подъёме.
|
||||
- [x] 3.6 `ApplyProviderSettings`, `ProviderSettings` и `ProviderName` убраны из
|
||||
пакета хранилища; `SessionDuration` убран вместе со сроком сессии.
|
||||
|
||||
## 4. Снос механики OIDC
|
||||
|
||||
- [x] 4.1 `internal/controller/http/auth.go` удалён целиком вместе с корнем
|
||||
`/auth` в перечне адресного пространства.
|
||||
- [x] 4.2 `SessionFromCookie` и `BlockSessionRefresh` удалены; места их привязки
|
||||
переписаны на новый слой.
|
||||
- [x] 4.3 `cmd/oidcstub` удалён.
|
||||
- [x] 4.4 Приложение: адрес, которым экран уводил ко входу, убран; неузнавание
|
||||
показывается строкой и отличается от прочей неудачи. Юнит-тесты приложения
|
||||
обновлены.
|
||||
|
||||
## 5. Способ представиться без прокси
|
||||
|
||||
- [x] 5.1 Заведён `cmd/devtools` с подкомандой `proxy`: слушает свой порт,
|
||||
ставит заголовок, переправляет запрос сервису. Подкоманду `admin` заводит
|
||||
задача `dev-run-task`. В образ пакет не едет: строка сборки `Dockerfile`
|
||||
называет точку входа поимённо.
|
||||
- [x] 5.2 `CLAUDE.md`, раздел «Команды» и раздел «Запреты» — про локальный вход
|
||||
без прокси; `README.md`, если он про это говорит.
|
||||
|
||||
## 6. Проверки
|
||||
|
||||
- [x] 6.1 Тест: первое обращение с доверенного адреса заводит учётную запись,
|
||||
второе с тем же значением попадает в ту же — в хранилище одна запись
|
||||
(критерий 1).
|
||||
- [x] 6.2 Тест: тот же заголовок с недоверенного адреса даёт `401`, и учётной
|
||||
записи не появляется (критерий 2).
|
||||
- [x] 6.3 Тест по перечню собственных адресов входа хранилища под
|
||||
`/api/collections/users/`: каждый отвечает отказом и учётной записи не
|
||||
меняет (критерий 3).
|
||||
- [x] 6.4 Тест: годный собственный токен побеждает заголовок, а протухший
|
||||
узнаванию не мешает.
|
||||
- [x] 6.4a Тест: узнанный не правит свою запись в коллекции пользователей
|
||||
запросом к хранилищу и не перечисляет коллекцию — путь захвата чужого имени
|
||||
закрыт.
|
||||
- [x] 6.4b Тест: пустой заголовок, два значения одного заголовка и значение
|
||||
сверх предела длины не узнают никого и записи не заводят.
|
||||
- [x] 6.4c Тест: два одновременных первых обращения одним значением дают одну
|
||||
запись.
|
||||
- [x] 6.4d Тест: занятый адрес почты не мешает завести запись — она заводится
|
||||
без почты.
|
||||
- [x] 6.4e Тест: запрос с заголовком на `GET /health` учётной записи не заводит.
|
||||
- [x] 6.4f Тест: прежние адреса под корнем `/auth` отдают разметку приложения.
|
||||
- [x] 6.5 Тест: значение заголовка не попадает в журнал.
|
||||
- [x] 6.6 Тест: ответ на успешный запрос не ставит браузеру куки.
|
||||
- [x] 6.7 Тест: токен файла выдаётся узнанному по заголовку, и путь к файлу
|
||||
записи проходит целиком.
|
||||
- [x] 6.8 Существующие тесты владельца и разграничения переписаны на новый способ
|
||||
представиться и остаются зелёными (критерий 5).
|
||||
- [x] 6.9 Тесты входа, обмена кода, куки, продления и выхода удалены вместе с
|
||||
предметом.
|
||||
|
||||
## 7. Документы и закрытие
|
||||
|
||||
- [x] 7.1 `docs/security.md`: периметр, недоверенный вход, разграничение доступа;
|
||||
четвёртый сдвиг про секрет в базе снят; требование к контуру — прокси
|
||||
**ставит** заголовок, а не пропускает пришедший — названо поимённо; там же
|
||||
цена переиспользования логина у провайдера.
|
||||
- [x] 7.2 `CLAUDE.md`: изъятие из инварианта о секрете снято.
|
||||
- [x] 7.3 `docs/architecture.md` приведён в соответствие; в «Единые точки
|
||||
проекта» добавлена строка про дом правила узнавания предъявителя.
|
||||
- [x] 7.3a `docs/passport.md`: строки про сессию OIDC у потребителей и в границе
|
||||
«Управление учётными записями» — учётную запись сервис не заводит по своей
|
||||
воле, а зеркалит имя, названное провайдером.
|
||||
- [x] 7.4 Поиск по `oidc`, `transcriber_session`, `transcriber_login`,
|
||||
`ApplyProviderSettings`, `/auth/` в дереве не находит живого кода
|
||||
(критерий 4).
|
||||
- [x] 7.5 `task gate` зелёный, кроме унаследованного `hadolint DL3066` — отказ
|
||||
воспроизводится на чистом `origin/master`, объявлен долгом в `CLAUDE.md`
|
||||
(критерий 4).
|
||||
|
||||
## 8. Отработка ревью кода
|
||||
|
||||
- [x] 8.1 Слой узнавания переехал за ограничитель частоты: отвергнутый запрос
|
||||
больше не заводит учётной записи.
|
||||
- [x] 8.2 Заведение учётной записи пишется в журнал; два значения заголовка —
|
||||
предупреждением, а не отладочным уровнем.
|
||||
- [x] 8.3 Имя и почта принимаются, а не кладутся как есть: длинное имя больше не
|
||||
запирает человека вечным отказом сервиса.
|
||||
- [x] 8.4 Отказ уникальности судится по коду, а не по имени колонки: негодная
|
||||
почта отличима от занятой.
|
||||
- [x] 8.5 Предел логина считается в знаках, а не в байтах.
|
||||
- [x] 8.6 Уникальный индекс по ключу сделан частичным — как соседний индекс
|
||||
почты; подъём на непустой базе больше не роняет накатку.
|
||||
- [x] 8.7 Ключ учётной записи закрыт от правки рукой в панели модельным хуком.
|
||||
- [x] 8.8 Хранилищу назван заголовок адреса спрашивающего: бюджет ограничителя
|
||||
перестал быть общим на весь сервис.
|
||||
- [x] 8.9 Приложение показывает текст сервера, своего словаря под коды ответа не
|
||||
заводит; текст `401` больше не зовёт ко входу, которого нет.
|
||||
- [x] 8.10 Оснастка берёт имена заголовков константами транспорта.
|
||||
- [x] 8.11 Три записи конвенций приведены к действительности; словарь полей
|
||||
журнала пополнен, изъятие про вывод оснастки записано.
|
||||
- [x] 8.12 Порог отката образа назван в «Эксплуатации»; требование к контуру про
|
||||
`X-Forwarded-For` и цена ширины перечня — в модели угроз.
|
||||
- [x] 8.13 Образец конфига сужен до адреса прокси.
|
||||
- [x] 7.6 Поведенческая проверка: сервис поднят локально, приложение открывается,
|
||||
учётная запись заводится первым обращением, запрос без заголовка получает
|
||||
отказ.
|
||||
+477
-321
@@ -2,11 +2,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера
|
||||
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
|
||||
открытыми.
|
||||
Кто пришёл в сервис и пускают ли его дальше: узнавание по заголовку доверенного
|
||||
источника, заведение учётной записи первым обращением и то, какие адреса
|
||||
остаются открытыми неузнанному.
|
||||
|
||||
Здесь же разграничение записей по владельцу: с 2026-08-14 сессия отвечает не
|
||||
Своего входа у сервиса нет. Собственный протокол OIDC — с адресом, уводящим к
|
||||
провайдеру, возвратом, кукой сессии и её сроком — жил здесь с 2026-08-12 по
|
||||
2026-08-22 и убран задачей `trusted-header-login`: пришедшего называет обратный
|
||||
прокси, сходивший к провайдеру, и делает это на каждом запросе.
|
||||
|
||||
Здесь же разграничение записей по владельцу: с 2026-08-14 узнавание отвечает не
|
||||
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
|
||||
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
|
||||
|
||||
@@ -15,360 +20,183 @@ OIDC, чем предъявляется сессия, что её прекращ
|
||||
вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход
|
||||
убран вместе с этим исключением.
|
||||
## Requirements
|
||||
### Requirement: Вход через внешнего провайдера
|
||||
|
||||
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
|
||||
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
|
||||
MUST не заводить: учётные записи держит провайдер, и это граница домена из
|
||||
паспорта.
|
||||
|
||||
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
|
||||
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
|
||||
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
|
||||
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
|
||||
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
|
||||
|
||||
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
|
||||
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
|
||||
выдавал. Без этой сверки вход принимает чужой код.
|
||||
|
||||
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
|
||||
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
|
||||
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
|
||||
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
|
||||
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
|
||||
средствами хранилища его требует.
|
||||
|
||||
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
|
||||
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
|
||||
|
||||
Уборка носителя MUST происходить до записи ответа. Отложенная не работает вовсе:
|
||||
заголовки фиксируются в момент, когда ответ начинают писать, и позднейшая правка
|
||||
до браузера не доезжает.
|
||||
|
||||
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
|
||||
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
|
||||
держит обработчик возврата открытым неограниченно долго, а «провайдер медленный»
|
||||
становится неотличим от «провайдер отказал».
|
||||
|
||||
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
|
||||
|
||||
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
|
||||
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
|
||||
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
|
||||
`GET` его срабатывание уносится переходом по чужой ссылке.
|
||||
|
||||
#### Scenario: Человек входит впервые
|
||||
|
||||
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
|
||||
- **WHEN** человек проходит вход и возвращается с кодом провайдера
|
||||
- **THEN** учётная запись заводится, а сессия открывается
|
||||
- **AND** дальнейший запрос к API от этой сессии проходит
|
||||
|
||||
#### Scenario: Признаки носителя состояния
|
||||
|
||||
- **WHEN** сервис уводит человека к провайдеру
|
||||
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
|
||||
что и кука сессии
|
||||
|
||||
#### Scenario: Возврат нельзя переиграть
|
||||
|
||||
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
|
||||
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
|
||||
#### Scenario: Возврат с чужим состоянием
|
||||
|
||||
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
|
||||
выдавал
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
- **AND** учётная запись не заводится
|
||||
|
||||
#### Scenario: Провайдер отказал
|
||||
|
||||
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
|
||||
### Requirement: Иных способов открыть сессию нет
|
||||
|
||||
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
|
||||
запись и получить сессию. Собственное создание записи в коллекции пользователей,
|
||||
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
|
||||
Сервис SHALL оставить собственные адреса входа хранилища неработающими: ни один
|
||||
из них MUST не давать доступа и MUST не менять учётной записи. Собственное
|
||||
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
|
||||
коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть
|
||||
выключены настройкой коллекции.
|
||||
|
||||
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
|
||||
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
|
||||
**Закрывается не только вход, но и правка.** Перечисление, чтение, создание,
|
||||
правка и удаление записи коллекции пользователей MUST быть закрыты правилами
|
||||
доступа — то есть оставлены пустыми, что у хранилища означает «только владелец
|
||||
панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих
|
||||
пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с
|
||||
кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть
|
||||
защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей
|
||||
записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил,
|
||||
панель работает суперпользователем, своих экранов профиля сервис не заводит —
|
||||
закрытие не стоит ничего.
|
||||
|
||||
Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то
|
||||
нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
|
||||
хранилища заводит коллекцию пользователей с открытым созданием записи и
|
||||
включённым входом по паролю, и без этого требования закрытие приёма обходится
|
||||
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
|
||||
включённым входом по паролю, и без этого требования узнавание по заголовку
|
||||
обходится двумя запросами: завести себе запись, войти по паролю, предъявить
|
||||
полученное.
|
||||
|
||||
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
|
||||
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
|
||||
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
|
||||
настоящему входу с этим адресом.
|
||||
Отдельная цена у открытого создания записи — захват учётной записи: запись,
|
||||
заведённая посторонним под чужим именем, досталась бы первому же настоящему
|
||||
обращению с этим именем.
|
||||
|
||||
Закрытие MUST не отменять заведения записи самим входом: запись при первом входе
|
||||
заводит внутренний запрос обмена, и правило, отвергающее его наравне с
|
||||
посторонним, оставляет сервис без единого способа войти.
|
||||
Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при
|
||||
первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему
|
||||
не судья.
|
||||
|
||||
#### Scenario: Завести учётную запись самому нельзя
|
||||
|
||||
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
|
||||
- **WHEN** запрос снаружи создаёт запись в коллекции пользователей
|
||||
- **THEN** ответ несёт отказ, а записи не появляется
|
||||
|
||||
#### Scenario: Вход у провайдера запись заводит
|
||||
#### Scenario: Обращение с заголовком запись заводит
|
||||
|
||||
- **GIVEN** учётной записи в сервисе ещё нет
|
||||
- **WHEN** человек проходит вход у провайдера
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** запрос с заголовком приходит с доверенного адреса
|
||||
- **THEN** учётная запись появляется
|
||||
|
||||
#### Scenario: Вход паролем недоступен
|
||||
|
||||
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
|
||||
- **THEN** ответ несёт отказ, а сессия не открывается
|
||||
- **THEN** ответ несёт отказ, а доступа не открывается
|
||||
|
||||
#### Scenario: Обмен кода у провайдера недоступен
|
||||
|
||||
- **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции
|
||||
пользователей
|
||||
- **THEN** ответ несёт отказ, а доступа не открывается
|
||||
|
||||
#### Scenario: Восстановление доступа недоступно
|
||||
|
||||
- **WHEN** запрос просит восстановление пароля или одноразовый код
|
||||
- **THEN** ответ несёт отказ
|
||||
|
||||
### Requirement: Сессия предъявляется кукой
|
||||
#### Scenario: Правка учётной записи снаружи закрыта
|
||||
|
||||
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
|
||||
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
|
||||
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
|
||||
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
|
||||
(`SameSite=Lax` или строже).
|
||||
- **GIVEN** человек узнан и его учётная запись заведена
|
||||
- **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу
|
||||
- **THEN** ответ несёт отказ, а запись остаётся прежней
|
||||
|
||||
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
|
||||
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
|
||||
#### Scenario: Перечисление учётных записей закрыто
|
||||
|
||||
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
|
||||
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
|
||||
Сервис MUST перекладывать значение куки в этот заголовок **только когда
|
||||
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
|
||||
кукой получал бы не то, что предъявил на собственных адресах хранилища.
|
||||
|
||||
Область действия слоя MUST быть ограничена **адресами приложения** — теми, что
|
||||
живут под его собственным корнем. Собственная поверхность хранилища под него не
|
||||
подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не
|
||||
шлёт, и расширение слоя на всё сняло бы эту защиту молча.
|
||||
|
||||
Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым
|
||||
адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия,
|
||||
предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.
|
||||
|
||||
#### Scenario: Кука открывает доступ
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка
|
||||
- **THEN** запрос проходит
|
||||
|
||||
#### Scenario: Кука защищена от чтения скриптом
|
||||
|
||||
- **WHEN** сервис ставит куку сессии
|
||||
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
|
||||
|
||||
#### Scenario: Предъявленный заголовок побеждает куку
|
||||
|
||||
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
|
||||
- **THEN** проверку проходит значение заголовка, а не куки
|
||||
|
||||
#### Scenario: Слой не расширяется на поверхность хранилища
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой
|
||||
- **THEN** значение куки в заголовок не перекладывается
|
||||
- **GIVEN** человек узнан
|
||||
- **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу
|
||||
- **THEN** ответ несёт отказ
|
||||
|
||||
### Requirement: Значение, дающее доступ, не печатается
|
||||
|
||||
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
|
||||
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
|
||||
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
|
||||
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
|
||||
собранные логи, откуда её не убрать.
|
||||
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
|
||||
которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя.
|
||||
|
||||
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
|
||||
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
|
||||
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
|
||||
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
|
||||
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
|
||||
не убрать. Требование того же рода, что и запрет писать имя файла в хранилище:
|
||||
там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым
|
||||
довольно назваться, чтобы стать этим человеком.
|
||||
|
||||
Причина отказа, пришедшая от провайдера строкой запроса, MUST приводиться к
|
||||
перечню известных: значение целиком задаёт тот, кто шлёт запрос, и без
|
||||
приведения аноним пишет в журнал что угодно и сколько угодно.
|
||||
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
|
||||
заголовка — тоже: это логин человека у провайдера.
|
||||
|
||||
#### Scenario: Значения сессии нет в журнале
|
||||
Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом,
|
||||
доступа сам по себе не даёт и без него путь запроса не прослеживается.
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он шлёт запрос к API с этой кукой
|
||||
- **THEN** значение сессии не встречается ни в одной журнальной записи
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Адреса почты нет в журнале
|
||||
|
||||
- **WHEN** человек проходит вход и учётная запись заводится
|
||||
- **THEN** адрес его почты не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Сессия переживает перезапуск сервиса
|
||||
|
||||
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
|
||||
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
|
||||
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
|
||||
|
||||
#### Scenario: Прежняя кука годна после перезапуска
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** сервис поднимается заново на том же хранилище
|
||||
- **THEN** запрос с прежней кукой проходит
|
||||
|
||||
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
|
||||
|
||||
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, и тем же числом
|
||||
задавать срок жизни куки. Умолчание хранилища MUST не применяться: оно даёт пять
|
||||
суток, и это число никем не выбрано.
|
||||
|
||||
Назначаться срок MUST при каждом подъёме, а не шагом схемы: применённый шаг не
|
||||
переписывается, и число, положенное туда, разошлось бы со сроком жизни куки при
|
||||
первой же правке — браузер получил бы новый срок, а хранилище продолжило выдавать
|
||||
прежний.
|
||||
|
||||
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
|
||||
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
|
||||
человек, которому провайдер закрыл доступ, работает до истечения своей сессии.
|
||||
Паспорт опирается на отзыв у провайдера как на способ остановить того, кто
|
||||
тратит слишком много, — значит срок сессии и есть цена этой остановки.
|
||||
|
||||
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
|
||||
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
|
||||
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
|
||||
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
|
||||
|
||||
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
|
||||
|
||||
#### Scenario: Сессия не продлевает саму себя
|
||||
|
||||
- **GIVEN** человек вошёл и получил сессию
|
||||
- **WHEN** этой же сессией он просит продлить её
|
||||
- **THEN** ответ несёт отказ, а нового значения в нём нет
|
||||
|
||||
#### Scenario: Сессия истекает назначенным сроком
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** назначенный срок прошёл
|
||||
- **THEN** запрос с этой кукой получает отказ
|
||||
|
||||
#### Scenario: Владелец закрывает чужую сессию
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** владелец обесценивает сессии этой учётной записи
|
||||
- **THEN** запрос с прежней кукой получает отказ
|
||||
|
||||
### Requirement: Выход прекращает доступ
|
||||
|
||||
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
|
||||
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
|
||||
у браузера. Куку сервис при этом MUST убрать тоже.
|
||||
|
||||
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
|
||||
продолжало бы открывать доступ до самого своего истечения.
|
||||
|
||||
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
|
||||
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
|
||||
человек уверен, что вышел.
|
||||
|
||||
#### Scenario: После выхода прежняя кука не работает
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
|
||||
- **THEN** запрос получает отказ
|
||||
|
||||
#### Scenario: Выход убирает куку
|
||||
|
||||
- **WHEN** человек выходит
|
||||
- **THEN** ответ убирает куку сессии у браузера
|
||||
- **WHEN** приходит первое обращение и учётная запись заводится
|
||||
- **THEN** адрес почты не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Кого пускать, решает провайдер
|
||||
|
||||
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
|
||||
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
|
||||
настройка выкладки, лежащая вне репозитория.
|
||||
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
|
||||
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
|
||||
сервиса — настройка выкладки, лежащая вне репозитория.
|
||||
|
||||
Требование записано именно как решение с ценой, а не как умолчание: провайдер
|
||||
общий для контура, и клиент, настроенный слишком широко, открывает сервис
|
||||
общий для контура, и правило, настроенное слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
|
||||
поэтому граница названа здесь и повторена в модели угроз.
|
||||
|
||||
#### Scenario: Пропущенный провайдером получает доступ
|
||||
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
|
||||
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
|
||||
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
|
||||
жизни этого значения. Теперь отзыв действует со следующего запроса.
|
||||
|
||||
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
|
||||
- **THEN** учётная запись заводится, а доступ открывается
|
||||
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
|
||||
для заведения записи
|
||||
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
|
||||
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
|
||||
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
|
||||
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
|
||||
|
||||
#### Scenario: Названный провайдером получает доступ
|
||||
|
||||
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
|
||||
прокси
|
||||
- **THEN** доступ открывается, а учётная запись заводится, если её не было
|
||||
- **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа
|
||||
и адреса почты
|
||||
|
||||
#### Scenario: Отзыв у провайдера действует со следующего запроса
|
||||
|
||||
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
|
||||
- **WHEN** приходит следующий его запрос
|
||||
- **THEN** прокси заголовка не ставит, и запрос получает отказ
|
||||
|
||||
### Requirement: Проба здоровья и метрики остаются открытыми
|
||||
|
||||
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
|
||||
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
|
||||
наблюдение за сервисом.
|
||||
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
|
||||
здоровья, ни у сборщика метрик учётной записи нет, и требование узнавания
|
||||
остановило бы наблюдение за сервисом.
|
||||
|
||||
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
|
||||
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
|
||||
адреса не несут.
|
||||
|
||||
#### Scenario: Проба здоровья доступна анонимно
|
||||
Заголовок, пришедший с недоверенного адреса, MUST не менять их ответа: узнавание
|
||||
отказа не выдаёт, и посторонняя строка в запросе не вправе гасить наблюдение.
|
||||
|
||||
- **WHEN** запрос приходит на `GET /health` без сессии
|
||||
#### Scenario: Проба здоровья доступна неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на `GET /health` без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Метрики доступны анонимно
|
||||
#### Scenario: Метрики доступны неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на `GET /metrics` без сессии
|
||||
- **WHEN** запрос приходит на `GET /metrics` без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: Секрет провайдера живёт в конфиге
|
||||
#### Scenario: Чужой заголовок наблюдения не гасит
|
||||
|
||||
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
|
||||
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
|
||||
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
|
||||
а не заводиться однажды шагом схемы.
|
||||
|
||||
Причина второго требования в необратимости шага схемы: применённый шаг не
|
||||
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
|
||||
вовсе — вход сломался бы после ротации.
|
||||
|
||||
Незаполненная или негодная настройка входа MUST ронять старт с перечнем ключей и
|
||||
без их значений. Форма адресов проверяется там же: непустая, но негодная строка
|
||||
иначе отвергается хранилищем позже — из хука подъёма, до регистрации пробы
|
||||
здоровья, — и сервис падает целиком, не оставив владельцу даже кода состояния.
|
||||
|
||||
#### Scenario: Секрета нет в журнале
|
||||
|
||||
- **WHEN** сервис поднимается с настроенным провайдером
|
||||
- **THEN** значение секрета не встречается ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Смена секрета доезжает до хранилища
|
||||
|
||||
- **GIVEN** сервис уже поднимался с прежним секретом
|
||||
- **WHEN** секрет в конфиге заменён и сервис поднят заново
|
||||
- **THEN** настройки провайдера в хранилище несут новое значение
|
||||
|
||||
#### Scenario: Негодная настройка роняет старт
|
||||
|
||||
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
|
||||
- **THEN** старт кончается отказом, а отказ называет имена ключей
|
||||
- **AND** значений этих ключей в отказе нет
|
||||
- **WHEN** запрос на `GET /health` приходит с недоверенного адреса с заголовком
|
||||
`Remote-User`
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||
|
||||
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
|
||||
имени которой запись принята, — и MUST отдавать данные такой записи только её
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
|
||||
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
|
||||
держит capability `storage`.
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
|
||||
конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и
|
||||
норму эту держит capability `storage`.
|
||||
|
||||
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
|
||||
доступа, ролей и передачи записи другому сервис не знает.
|
||||
|
||||
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
|
||||
Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
|
||||
имя.
|
||||
|
||||
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
|
||||
@@ -387,20 +215,20 @@ capability `archive`: там живут адреса чтения записи,
|
||||
|
||||
#### Scenario: Своя запись доступна
|
||||
|
||||
- **GIVEN** человек вошёл и принял запись
|
||||
- **WHEN** он спрашивает карточку этой записи своей сессией
|
||||
- **GIVEN** человек узнан и принял запись
|
||||
- **WHEN** он спрашивает карточку этой записи
|
||||
- **THEN** ответ несёт данные записи
|
||||
|
||||
#### Scenario: Чужая запись неотличима от несуществующей
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим
|
||||
- **WHEN** её карточку спрашивает другой вошедший
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** её карточку спрашивает другой узнанный
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Владельца не задают запросом
|
||||
|
||||
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||
- **THEN** владельцем принятой записи становится предъявитель сессии
|
||||
- **THEN** владельцем принятой записи становится узнанный предъявитель
|
||||
|
||||
#### Scenario: Ничью запись завести нечем
|
||||
|
||||
@@ -415,65 +243,393 @@ capability `archive`: там живут адреса чтения записи,
|
||||
|
||||
### Requirement: Приложение узнаёт вошедшего
|
||||
|
||||
Сервис SHALL отдавать приложению сведения о том, кто вошёл, — `GET /app/me` — и
|
||||
MUST отвечать отказом `401`, когда сессии нет. Своей страницы со скриптом,
|
||||
которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает
|
||||
разметку само и вошедшего узнаёт ответом.
|
||||
Сервис SHALL отдавать приложению сведения о том, кто пришёл, — `GET /app/me` — и
|
||||
MUST отвечать отказом `401`, когда пришедший не узнан. Своей страницы со
|
||||
скриптом, которой сервер отрисовал бы имя пришедшего, у сервиса нет: приложение
|
||||
собирает разметку само и пришедшего узнаёт ответом.
|
||||
|
||||
Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не
|
||||
может вовсе — этот адрес единственный способ его узнать.
|
||||
Заголовок ставит прокси, и прочитать его из браузера приложение не может вовсе —
|
||||
этот адрес единственный способ узнать, кто пришёл.
|
||||
|
||||
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
|
||||
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
|
||||
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
|
||||
ему выходить наружу наравне с журналом.
|
||||
|
||||
#### Scenario: Вошедший узнан
|
||||
#### Scenario: Пришедший узнан
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** приложение спрашивает, кто вошёл
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** приложение спрашивает, кто пришёл
|
||||
- **THEN** ответ несёт идентификатор его учётной записи
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
#### Scenario: Пришедший не узнан
|
||||
|
||||
- **WHEN** приложение спрашивает, кто вошёл, без сессии
|
||||
- **WHEN** приложение спрашивает, кто пришёл, без заголовка
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа не несёт учётной записи
|
||||
|
||||
#### Scenario: Адреса почты в ответе нет
|
||||
|
||||
- **GIVEN** человек вошёл, и у его учётной записи есть адрес почты
|
||||
- **WHEN** приложение спрашивает, кто вошёл
|
||||
- **GIVEN** пришедший узнан, и у его учётной записи есть адрес почты
|
||||
- **WHEN** приложение спрашивает, кто пришёл
|
||||
- **THEN** адреса почты в ответе нет
|
||||
|
||||
### Requirement: Приложение отдаётся без сессии
|
||||
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень
|
||||
адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба
|
||||
здоровья и метрики.
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы неузнанному. Перечень
|
||||
адресов, открытых без узнавания, пополняется ими: прежде в нём стояли только
|
||||
проба здоровья и метрики.
|
||||
|
||||
Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через
|
||||
приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена
|
||||
открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого
|
||||
записей не несут: они одинаковы для всех и собираются до всякого запроса.
|
||||
Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал,
|
||||
до приложения не доходит вовсе. Разметка при этом обязана отдаваться и ему —
|
||||
иначе неудача узнавания выглядела бы поломкой сервиса, а не отказом входа. Цена
|
||||
открытости названа здесь же и невелика: ни разметка, ни ресурсы содержимого
|
||||
записей не несут — они одинаковы для всех и собираются до всякого запроса.
|
||||
|
||||
Открытость MUST не касаться данных: всякий адрес под корнем приложения
|
||||
по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни
|
||||
одной записи.
|
||||
по-прежнему требует узнанного предъявителя, и приложение, открытое неузнанным,
|
||||
не получает ни одной записи.
|
||||
|
||||
#### Scenario: Разметка доступна анонимно
|
||||
#### Scenario: Разметка доступна неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на корень сервиса без сессии
|
||||
- **WHEN** запрос приходит на корень сервиса без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Ресурс приложения доступен анонимно
|
||||
#### Scenario: Ресурс приложения доступен неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на ресурс приложения без сессии
|
||||
- **WHEN** запрос приходит на ресурс приложения без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Данные анонимно не отдаются
|
||||
#### Scenario: Данные неузнанному не отдаются
|
||||
|
||||
- **GIVEN** приложение открыто без сессии
|
||||
- **GIVEN** приложение открыто без заголовка
|
||||
- **WHEN** оно спрашивает список записей
|
||||
- **THEN** ответ имеет код `401`
|
||||
|
||||
### Requirement: Пришедшего называет доверенный источник
|
||||
|
||||
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
|
||||
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
|
||||
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
|
||||
не остаётся.
|
||||
|
||||
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
|
||||
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
|
||||
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
|
||||
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
|
||||
барьер вовсе.
|
||||
|
||||
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
|
||||
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
|
||||
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
|
||||
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
|
||||
требованием учётной записи на адресах приложения.
|
||||
|
||||
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает
|
||||
хранилище, а на новом имени ещё и пишет в него; выполненное раньше ограничителя,
|
||||
оно работало бы на запросах, которые тот уже отверг, и поток отвергнутых
|
||||
обращений заводил бы учётные записи, которые потом не убираются ничем.
|
||||
|
||||
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
|
||||
Область — корень приложения и адрес, которым хранилище выдаёт короткий токен
|
||||
файла; она MUST выводиться из объявленного адресного пространства сервиса, а не
|
||||
перечисляться вторым списком. Собственная поверхность хранилища под узнавание
|
||||
MUST не подпадать: правка учётной записи, её чтение и перечисление коллекции
|
||||
пользователей остаются недостижимыми для узнанного, потому что ключ учётной
|
||||
записи лежит там обычной колонкой, а правило правки у коллекции — библиотечное и
|
||||
разрешает править свою запись. Расширение области на всё дало бы узнанному
|
||||
переписать себе ключ на чужое имя и забрать чужой архив.
|
||||
|
||||
Сужение области закрывает и вторую вещь: узнавание MUST не срабатывать на пробе
|
||||
здоровья, на метриках и на ресурсах приложения. Иначе запрос за каждой картинкой
|
||||
стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё.
|
||||
|
||||
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
|
||||
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
|
||||
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
|
||||
без этой нормы все неназванные собрались бы в одну учётную запись с общим
|
||||
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
|
||||
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
|
||||
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
|
||||
бы вход анониму. Значение сверх объявленного предела длины и значение с
|
||||
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
|
||||
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
|
||||
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
|
||||
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
|
||||
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
|
||||
считает колонка.
|
||||
|
||||
Отказ хранилища при узнавании MUST кончаться отказом сервиса, а не молчаливым
|
||||
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
|
||||
|
||||
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
|
||||
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
|
||||
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
|
||||
настройке журнала: обе строки означают поломку контура, а поломка, записанная
|
||||
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
|
||||
которого никто не может войти, не отличит своей поломки (перечень доверенных
|
||||
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
|
||||
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
|
||||
нести значения заголовка.
|
||||
|
||||
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
|
||||
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
|
||||
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
|
||||
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
|
||||
|
||||
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
|
||||
MUST не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же:
|
||||
**короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по
|
||||
ссылке на файл записи; его нормирует capability `storage`, а срок его жизни
|
||||
назначается числом и живёт там, где проект держит числовые настройки. На этот
|
||||
срок — и только на него — отзыв доступа до файловой ссылки не доходит.
|
||||
|
||||
В остальном смысл именно таков: отзыв доступа судит провайдер на каждом
|
||||
обращении, а не однажды выданный срок.
|
||||
|
||||
Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок:
|
||||
владелец панели предъявляет свой, и подмена его учётной записью пользователя
|
||||
отобрала бы у него панель посреди работы.
|
||||
|
||||
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
|
||||
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
|
||||
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
|
||||
адресом его почты.
|
||||
|
||||
#### Scenario: Заголовок с доверенного адреса узнаёт человека
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
|
||||
- **THEN** запрос идёт от имени учётной записи с этим значением
|
||||
|
||||
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
|
||||
|
||||
- **GIVEN** адреса источника в перечне доверенных нет
|
||||
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи с этим значением не появляется
|
||||
|
||||
#### Scenario: Предъявленный токен побеждает заголовок
|
||||
|
||||
- **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен
|
||||
хранилища
|
||||
- **WHEN** сервис решает, кто пришёл
|
||||
- **THEN** пришедшим считается предъявитель токена
|
||||
|
||||
#### Scenario: Протухший токен узнаванию не мешает
|
||||
|
||||
- **GIVEN** запрос несёт заголовок `Remote-User` и негодный либо протухший токен
|
||||
хранилища
|
||||
- **WHEN** сервис решает, кто пришёл
|
||||
- **THEN** пришедшим считается названный заголовком
|
||||
|
||||
#### Scenario: Пустой заголовок не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Два значения одного заголовка не узнают никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Значение сверх предела длины не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Поверхность хранилища узнаванию не подпадает
|
||||
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** он правит запись коллекции пользователей собственным адресом
|
||||
хранилища
|
||||
- **THEN** правка не проходит
|
||||
|
||||
#### Scenario: Проба здоровья учётной записи не заводит
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
|
||||
- **THEN** учётной записи не появляется
|
||||
|
||||
#### Scenario: Недоверенный источник виден в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком приходит с недоверенного адреса
|
||||
- **THEN** журнал несёт строку об этом исходе с адресом пира
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
#### Scenario: Сервис не ставит браузеру куки
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения проходит с заголовком
|
||||
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
|
||||
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Учётная запись заводится первым обращением
|
||||
|
||||
Сервис SHALL заводить учётную запись при первом обращении с новым значением
|
||||
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
|
||||
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
|
||||
коллекции пользователей.
|
||||
|
||||
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
|
||||
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
|
||||
пределу колонки и чистится от управляющих знаков, негодный адрес почты
|
||||
отбрасывается. Негодное значение необязательного поля MUST не отменять
|
||||
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
|
||||
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе
|
||||
всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы
|
||||
карточку человека молча, посреди его работы.
|
||||
|
||||
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
|
||||
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
|
||||
с чужим адресом досталось бы чужой записи.
|
||||
|
||||
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
|
||||
Ни запросом снаружи, ни рукой в панели: переписанный ключ отдаёт архив
|
||||
следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи
|
||||
назначается один раз и не меняется. Правило доступа коллекции пользователей MUST
|
||||
закрывать правку записи снаружи наглухо, и MUST это держать схема, а не область
|
||||
действия узнавания: защита, стоящая на том, что до поверхности хранилища никто не
|
||||
дотянется, однажды уже оказалась случайной.
|
||||
|
||||
Одновременные первые обращения одним значением MUST кончаться одной учётной
|
||||
записью: уникальность держит схема, а не порядок обращений.
|
||||
|
||||
**Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой
|
||||
колонке — это гонка двух первых обращений одним именем, и он MUST кончаться
|
||||
повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес
|
||||
почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться
|
||||
заведением записи **без почты**: она необязательна. Без этого разреза второй
|
||||
человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск
|
||||
по имени снова ничего не находит.
|
||||
|
||||
Цена ключа называется целиком, обеими сторонами. Переименование пользователя у
|
||||
провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней;
|
||||
слить их или убрать нечем — владелец записи не меняется, а учётная запись с
|
||||
записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек,
|
||||
которому провайдер выдал логин ушедшего, при первом обращении попадает в
|
||||
существующую запись и получает весь её архив. Не допускать переиспользования —
|
||||
работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта
|
||||
цена принимается, а не обходится.
|
||||
|
||||
#### Scenario: Первое обращение заводит запись
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит запрос с заголовком `Remote-User`
|
||||
- **THEN** учётная запись появляется
|
||||
- **AND** запрос идёт от её имени
|
||||
|
||||
#### Scenario: Повторное обращение попадает в ту же запись
|
||||
|
||||
- **GIVEN** учётная запись заведена первым обращением
|
||||
- **WHEN** приходит второй запрос с тем же значением заголовка
|
||||
- **THEN** новой учётной записи не появляется
|
||||
- **AND** запрос идёт от имени прежней
|
||||
|
||||
#### Scenario: Разным значениям — разные записи
|
||||
|
||||
- **WHEN** приходят запросы с двумя разными значениями заголовка
|
||||
- **THEN** заводятся две учётные записи
|
||||
- **AND** записи одного не видны другому
|
||||
|
||||
#### Scenario: Имя не переписывается вторым обращением
|
||||
|
||||
- **GIVEN** учётная запись заведена с одним значением `Remote-Name`
|
||||
- **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name`
|
||||
- **THEN** имя учётной записи остаётся прежним
|
||||
|
||||
#### Scenario: Два одновременных первых обращения дают одну запись
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** два запроса с одним значением заголовка приходят одновременно
|
||||
- **THEN** в коллекции пользователей появляется ровно одна запись
|
||||
- **AND** оба запроса идут от её имени
|
||||
|
||||
#### Scenario: Занятая почта не мешает завести запись
|
||||
|
||||
- **GIVEN** учётная запись с этим адресом почты уже заведена
|
||||
- **WHEN** приходит первое обращение с другим `Remote-User` и тем же
|
||||
`Remote-Email`
|
||||
- **THEN** заводится своя учётная запись
|
||||
- **AND** адреса почты у неё нет
|
||||
|
||||
#### Scenario: Ключ учётной записи не правится и рукой в панели
|
||||
|
||||
- **GIVEN** учётная запись заведена
|
||||
- **WHEN** её ключ меняют сохранением записи мимо адресов приложения
|
||||
- **THEN** сохранение отвергается, а ключ остаётся прежним
|
||||
|
||||
#### Scenario: Негодное имя не отменяет заведения
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит обращение с именем длиннее предела колонки
|
||||
- **THEN** учётная запись заводится, а имя обрезано по пределу
|
||||
|
||||
#### Scenario: Негодная почта отбрасывается, а не отменяет заведение
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит обращение с адресом почты, не похожим на адрес
|
||||
- **THEN** учётная запись заводится без почты
|
||||
|
||||
#### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит
|
||||
|
||||
- **GIVEN** бюджет ограничителя частоты выбран
|
||||
- **WHEN** приходит обращение с новым значением заголовка
|
||||
- **THEN** ответ несёт отказ ограничителя
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Заведение учётной записи видно в журнале
|
||||
|
||||
- **WHEN** приходит первое обращение с новым значением заголовка
|
||||
- **THEN** журнал несёт строку о заведении с идентификатором записи
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
#### Scenario: Ключ учётной записи снаружи не правится
|
||||
|
||||
- **GIVEN** человек узнан и его учётная запись заведена
|
||||
- **WHEN** он правит ключ своей учётной записи запросом к хранилищу
|
||||
- **THEN** правка не проходит, а ключ остаётся прежним
|
||||
|
||||
### Requirement: Доверенный источник объявлен настройкой
|
||||
|
||||
Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт,
|
||||
когда перечень пуст либо его строки не читаются как адрес или подсеть. Пустой
|
||||
перечень значит «не верить никому»: сервис поднялся бы никого не узнающим, а
|
||||
узнать об этом было бы неоткуда.
|
||||
|
||||
Отказ старта MUST называть имя ключа. Ни адресов провайдера, ни идентификатора
|
||||
клиента, ни секрета клиента в конфиге MUST не быть: менять код больше не на что,
|
||||
и секрет уходит из конфига вместе с протоколом.
|
||||
|
||||
Перечень MUST называться строкой журнала при подъёме. Сервис, никого не узнающий
|
||||
из-за неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
||||
доходит вовсе, — а это разные поломки в разных местах.
|
||||
|
||||
#### Scenario: Пустой перечень роняет старт
|
||||
|
||||
- **WHEN** сервис поднимается с пустым перечнем доверенных адресов
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** отказ называет имя ключа
|
||||
|
||||
#### Scenario: Негодная строка перечня роняет старт
|
||||
|
||||
- **WHEN** сервис поднимается с перечнем, где строка не читается как адрес или
|
||||
подсеть
|
||||
- **THEN** старт кончается отказом
|
||||
|
||||
#### Scenario: Перечень виден в журнале подъёма
|
||||
|
||||
- **WHEN** сервис поднимается с заполненным перечнем
|
||||
- **THEN** журнал подъёма называет доверенные адреса
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
|
||||
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
|
||||
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
|
||||
|
||||
- отсутствие сессии — `401`, и он MUST наступать **до всякого чтения записи**,
|
||||
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
|
||||
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
|
||||
разнице кодов перебирается список заведённых записей;
|
||||
- узнанный предъявитель без учётной записи пользователя — `403`;
|
||||
@@ -130,16 +130,16 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
|
||||
- **AND** код отказа принадлежит закрытому перечню
|
||||
- **AND** ни одно из них не содержит сырого текста ошибки
|
||||
|
||||
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
|
||||
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
|
||||
|
||||
- **GIVEN** заведена запись
|
||||
- **WHEN** её карточку спрашивают без сессии, а затем спрашивают карточку по
|
||||
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
|
||||
неизвестному идентификатору
|
||||
- **THEN** оба ответа имеют код `401` и одно тело
|
||||
|
||||
#### Scenario: Запись сверх потолка размера
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **GIVEN** отправитель узнан
|
||||
- **WHEN** он шлёт запись длиннее потолка размера
|
||||
- **THEN** ответ имеет код `413`, а тело несёт предел числом
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
|
||||
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
Запрос от неузнанного MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
|
||||
получить заведённую под неё аудиозапись на рубеже `uploaded`.
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
самый частый отказ у человека на мобильной сети — прежде не был нормирован
|
||||
ничем и уходил телом ограничителя тела, мимо единой формы.
|
||||
|
||||
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
|
||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
@@ -59,20 +59,20 @@
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
|
||||
Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность
|
||||
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
|
||||
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
|
||||
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
|
||||
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
|
||||
файла.
|
||||
|
||||
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
|
||||
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
|
||||
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
|
||||
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
|
||||
стать не может.
|
||||
Предъявитель, узнанный без учётной записи пользователя, MUST получать
|
||||
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ
|
||||
неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно
|
||||
такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него
|
||||
нет, и владельцем записи он стать не может.
|
||||
|
||||
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
|
||||
Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит
|
||||
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
|
||||
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
|
||||
|
||||
@@ -83,31 +83,31 @@
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **AND** отправитель узнан
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
|
||||
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
|
||||
место под признак повторного файла
|
||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
|
||||
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
|
||||
|
||||
#### Scenario: Сессия не даёт учётной записи пользователя
|
||||
#### Scenario: Узнанный без учётной записи пользователя
|
||||
|
||||
- **GIVEN** предъявлена сессия владельца панели
|
||||
- **GIVEN** предъявлен собственный токен владельца панели
|
||||
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
|
||||
- **THEN** ответ имеет код `403`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
#### Scenario: Пришедший не узнан
|
||||
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` без сессии
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
- **AND** тело ответа не несёт данных записи
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **GIVEN** отправитель узнан
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
|
||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
@@ -115,7 +115,7 @@
|
||||
#### Scenario: Размеру записи приём не судья
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **AND** отправитель узнан
|
||||
- **WHEN** программа шлёт запись нулевой длины
|
||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||
|
||||
|
||||
@@ -107,19 +107,21 @@ MUST завести свою схему и принимать записи св
|
||||
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||||
|
||||
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
||||
коллекции. Правило MUST пускать только владельца файла: незаданное означает
|
||||
«только владелец панели», и тогда файла не получит и вошедший, а прежнее «всякий
|
||||
узнанный» отдавало чужое аудио тому, кто знает идентификатор записи.
|
||||
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
|
||||
Правило MUST пускать только владельца файла: незаданное означает «только владелец
|
||||
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
|
||||
отдавало чужое аудио тому, кто знает идентификатор записи.
|
||||
|
||||
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
|
||||
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
|
||||
токена: отказ наступает там, и требовать его от выдачи значит требовать
|
||||
механизма, которого нет.
|
||||
|
||||
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
||||
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
||||
недосмотр.
|
||||
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
|
||||
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
|
||||
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
|
||||
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
|
||||
хранилища, а не недосмотр.
|
||||
|
||||
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||||
хранилища, а не по ссылке.
|
||||
@@ -133,7 +135,7 @@ MUST завести свою схему и принимать записи св
|
||||
бессрочно.
|
||||
|
||||
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||||
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
|
||||
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
|
||||
половину ключа.
|
||||
|
||||
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||||
@@ -147,17 +149,23 @@ MUST завести свою схему и принимать записи св
|
||||
#### Scenario: Файл забирают по ссылке
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **AND** забирающий предъявил сессию и взял по ней токен файла
|
||||
- **AND** забирающий узнан и взял токен файла
|
||||
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||
|
||||
#### Scenario: Без сессии файл не отдаётся
|
||||
#### Scenario: Неузнанному файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **WHEN** ссылку на файл запрашивают без сессии
|
||||
- **WHEN** ссылку на файл запрашивают неузнанным
|
||||
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||
|
||||
#### Scenario: Конвейер читает файл без сессии
|
||||
#### Scenario: Токен файла выдаётся узнанному по заголовку
|
||||
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** он просит у хранилища токен файла
|
||||
- **THEN** токен выдаётся
|
||||
|
||||
#### Scenario: Конвейер читает файл без узнавания
|
||||
|
||||
- **GIVEN** запись принята и ждёт расшифровки
|
||||
- **WHEN** шаг конвейера берётся за неё
|
||||
@@ -324,7 +332,7 @@ MUST завести свою схему и принимать записи св
|
||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||||
|
||||
Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
|
||||
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
|
||||
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
|
||||
значение оставалось у файлов, заведённых конвейером для записи без владельца;
|
||||
таких записей больше не заводится, и разное правило у записи и у её файла
|
||||
@@ -348,8 +356,8 @@ MUST получать владельца своей записи. Иного и
|
||||
|
||||
#### Scenario: Чужой файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим
|
||||
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
|
||||
- **THEN** содержимого он не получает
|
||||
|
||||
#### Scenario: Свой файл отдаётся
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
Спека отвечает за **сервис**, а не за сборщик: правило неизвестного пути, срок
|
||||
хранения ответов, поведение при несобранном приложении и то, что уходит в журнал.
|
||||
Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Приложение отдаётся самим бинарником
|
||||
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника.
|
||||
@@ -71,8 +69,15 @@
|
||||
|
||||
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
|
||||
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
|
||||
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа,
|
||||
`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`.
|
||||
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, —
|
||||
отдельными адресами стоят `/health` и `/metrics`.
|
||||
|
||||
Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и
|
||||
адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем
|
||||
же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя
|
||||
за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается
|
||||
от любого другого свободного имени, а второй перечень «когда-то занятых корней»
|
||||
разошёлся бы с первым молча.
|
||||
|
||||
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
|
||||
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
|
||||
@@ -114,16 +119,23 @@
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Прежний адрес входа открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на путь под прежним корнем входа
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
|
||||
|
||||
- **GIVEN** человек вошёл и предъявил сессию
|
||||
- **GIVEN** запрос идёт с заголовком, поставленным прокси
|
||||
- **WHEN** он спрашивает неизвестный путь под корнем приложения
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса
|
||||
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
|
||||
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
|
||||
заголовка
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
@@ -138,22 +150,6 @@
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Наблюдение приложением не подменяется
|
||||
|
||||
- **WHEN** запрос приходит на пробу здоровья
|
||||
- **THEN** отвечает проба здоровья, а не приложение
|
||||
|
||||
#### Scenario: Чужой метод отвечает отказом
|
||||
|
||||
- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым
|
||||
страницу не открывают
|
||||
- **THEN** ответ имеет код `405`
|
||||
|
||||
#### Scenario: Проверка доступности разметку получает
|
||||
|
||||
- **WHEN** разметку спрашивают методом `HEAD`
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: Обновлённое приложение доходит до браузера
|
||||
|
||||
Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим
|
||||
@@ -239,44 +235,41 @@
|
||||
|
||||
### Requirement: Открытое приложение показывает вошедшего
|
||||
|
||||
Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ
|
||||
`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком
|
||||
адресе данных, и это его штатное состояние, а не поломка.
|
||||
Приложение SHALL спрашивать сервис, кто пришёл, и показывать его имя. Отказ
|
||||
`401` MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
|
||||
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
|
||||
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
|
||||
только мимо него.
|
||||
|
||||
Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться
|
||||
строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг:
|
||||
приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе
|
||||
сервиса или ограничителя частоты круг пошёл бы заново.
|
||||
Всякий **иной** отказ и сорванный запрос MUST показываться строкой о неудаче.
|
||||
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
|
||||
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
|
||||
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
|
||||
перехода.
|
||||
|
||||
Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна
|
||||
скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в
|
||||
ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен,
|
||||
и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме
|
||||
`access`.
|
||||
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
|
||||
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
|
||||
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
|
||||
адрес почты: его в ответе нет по норме `access`.
|
||||
|
||||
#### Scenario: Вошедший виден
|
||||
#### Scenario: Узнанный виден
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение показывает его имя
|
||||
|
||||
#### Scenario: Не вошедшему предлагается вход
|
||||
#### Scenario: Неузнанному показывают, что его не узнали
|
||||
|
||||
- **GIVEN** сессии у человека нет
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение ведёт его ко входу
|
||||
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
|
||||
- **WHEN** человек открывает приложение
|
||||
- **THEN** приложение показывает строку о том, что его не узнали
|
||||
- **AND** никуда его не уводит
|
||||
|
||||
#### Scenario: Отказ сервиса ко входу не уводит
|
||||
#### Scenario: Отказ сервиса от неузнавания отличается
|
||||
|
||||
- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является
|
||||
отсутствием сессии
|
||||
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
|
||||
неузнаванием
|
||||
- **WHEN** человек открывает приложение
|
||||
- **THEN** приложение показывает строку о неудаче
|
||||
- **AND** ко входу оно не уводит
|
||||
- **AND** эта строка не та, которой оно сообщает о неузнавании
|
||||
|
||||
#### Scenario: Учётная запись без имени
|
||||
|
||||
- **GIVEN** человек вошёл, а имени у его учётной записи нет
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение показывает, что вход выполнен
|
||||
- **AND** адреса почты на экране нет
|
||||
|
||||
+15
-10
@@ -17,14 +17,15 @@ interface ErrorBody {
|
||||
}
|
||||
|
||||
/**
|
||||
* Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи:
|
||||
* экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт,
|
||||
* иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение →
|
||||
* вход → приложение».
|
||||
* Исход запроса. «Сервис вас не узнал» — свой исход, а не разновидность
|
||||
* неудачи: человек по нему делает не то, что по «сервис не отвечает». Прежде
|
||||
* этот исход уводил ко входу; уводить стало некуда — своего входа у сервиса
|
||||
* нет, а заголовок ставит обратный прокси, — и различие сохраняется ради
|
||||
* текста, а не ради перехода.
|
||||
*/
|
||||
export type ApiResult<T> =
|
||||
| { status: 'ok'; value: T }
|
||||
| { status: 'unauthorized' }
|
||||
| { status: 'unrecognized'; message: string }
|
||||
| { status: 'failed'; message: string }
|
||||
|
||||
const noConnectionMessage = 'Связи нет'
|
||||
@@ -54,7 +55,11 @@ async function request<T>(path: string): Promise<ApiResult<T>> {
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return { status: 'unauthorized' }
|
||||
// Текст берётся у сервера, а не сочиняется здесь: единая форма отказа —
|
||||
// обязанность API, и второй словарь на клиенте разошёлся бы с первым.
|
||||
// Свой исход у этого кода всё равно есть: «сервис вас не узнал» и «сервис
|
||||
// не отвечает» ведут человека к разному.
|
||||
return { status: 'unrecognized', message: await failureMessage(response) }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -68,10 +73,10 @@ async function request<T>(path: string): Promise<ApiResult<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */
|
||||
/**
|
||||
* Спрашивает сервис, кто пришёл. Заголовок ставит обратный прокси, и приложение
|
||||
* его не видит вовсе — этот адрес единственный способ узнать вошедшего.
|
||||
*/
|
||||
export function fetchMe(): Promise<ApiResult<Me>> {
|
||||
return request<Me>('/me')
|
||||
}
|
||||
|
||||
/** Адрес, которым сервис уводит человека ко входу. */
|
||||
export const loginPath = '/auth/login'
|
||||
|
||||
@@ -4,6 +4,10 @@ import HomeScreen from './HomeScreen.vue'
|
||||
|
||||
// Экран судится по тому, что видит человек, а не по внутреннему состоянию:
|
||||
// переписанная реализация обязана остаться зелёной.
|
||||
//
|
||||
// Переход к адресу входа проверяется тем, что его **нет**: своего входа у
|
||||
// сервиса не осталось, и экран, снова начавший куда-то уводить, обязан упасть
|
||||
// здесь, а не обнаружиться пустой страницей у человека.
|
||||
|
||||
const assign = vi.fn()
|
||||
|
||||
@@ -23,7 +27,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('экран показывает вошедшего', () => {
|
||||
it('показывает имя, когда сессия есть', async () => {
|
||||
it('показывает имя, когда сервис узнал пришедшего', async () => {
|
||||
answerWith({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -37,16 +41,43 @@ describe('экран показывает вошедшего', () => {
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ведёт ко входу, когда сессии нет', async () => {
|
||||
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||
|
||||
mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(assign).toHaveBeenCalledWith('/auth/login')
|
||||
it('показывает текст сервера, когда тот не узнал пришедшего, и никуда не уводит', async () => {
|
||||
answerWith({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ error_code: 'unauthorized', message: 'Сервис вас не узнал' }),
|
||||
})
|
||||
|
||||
it('на прочий отказ показывает строку и ко входу не ведёт', async () => {
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Сервис вас не узнал')
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('на неузнавание без текста сервера показывает общее, а не пустоту', async () => {
|
||||
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Сервис не отвечает как надо')
|
||||
})
|
||||
|
||||
it('на неразобранное тело показывает строку, а не падает', async () => {
|
||||
answerWith({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.reject(new SyntaxError('not json')),
|
||||
})
|
||||
|
||||
const screen = mount(HomeScreen)
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.text()).toContain('Сервис не отвечает как надо')
|
||||
})
|
||||
|
||||
it('прочий отказ отличается от неузнавания', async () => {
|
||||
answerWith({
|
||||
ok: false,
|
||||
status: 500,
|
||||
@@ -70,7 +101,7 @@ describe('экран показывает вошедшего', () => {
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('учётная запись без имени: вход выполнен, почты на экране нет', async () => {
|
||||
it('учётная запись без имени: человек узнан, почты на экране нет', async () => {
|
||||
answerWith({
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchMe, loginPath } from '../api'
|
||||
import { fetchMe } from '../api'
|
||||
|
||||
// Состояние экрана живёт в экране: общего между экранами пока нет.
|
||||
const state = ref<'loading' | 'signed-in' | 'failed'>('loading')
|
||||
const state = ref<'loading' | 'signed-in' | 'unrecognized' | 'failed'>('loading')
|
||||
const name = ref('')
|
||||
const failure = ref('')
|
||||
const unrecognized = ref('')
|
||||
|
||||
async function load() {
|
||||
const result = await fetchMe()
|
||||
|
||||
if (result.status === 'unauthorized') {
|
||||
// Единственная ветка, уводящая ко входу. Прочие отказы ведут сюда же
|
||||
// только через круг «приложение → вход → приложение», поэтому их здесь нет.
|
||||
window.location.assign(loginPath)
|
||||
if (result.status === 'unrecognized') {
|
||||
// Уводить некуда: своего входа у сервиса нет, а имя пришедшего ставит
|
||||
// обратный прокси. Показываем строкой — той, что прислал сервер, — и
|
||||
// отдельной от прочей неудачи: «вас не узнали» и «сервис не отвечает»
|
||||
// ведут человека к разному.
|
||||
unrecognized.value = result.message
|
||||
state.value = 'unrecognized'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,6 +40,8 @@ onMounted(load)
|
||||
|
||||
<p v-if="state === 'loading'">Загружается…</p>
|
||||
|
||||
<p v-else-if="state === 'unrecognized'">{{ unrecognized }}</p>
|
||||
|
||||
<p v-else-if="state === 'failed'">{{ failure }}</p>
|
||||
|
||||
<!-- Имени у учётной записи может не быть вовсе: тогда говорим, что вход
|
||||
|
||||
Reference in New Issue
Block a user