From 7f33c957e509fc31f4a72aa6a8710bc59f951789 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 22 Aug 2026 20:24:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D1=85=D0=B0=D0=BB=20=D0=BD=D0=B0=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BE=D0=BA=20Authelia=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=81=D0=BE=D0=B1=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20OIDC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом --- CLAUDE.md | 43 +- Dockerfile | 11 +- README.md | 31 +- cmd/devtools/main.go | 126 +++ cmd/oidcstub/main.go | 178 ---- cmd/transcriber/journal_route_test.go | 1 - cmd/transcriber/main.go | 52 +- config.example.toml | 75 +- ...-2026-08-12-oidc-exchange-via-own-route.md | 1 + .../ADR-2026-08-12-session-without-refresh.md | 1 + .../ADR-2026-08-22-login-by-trusted-header.md | 73 ++ docs/adr/README.md | 5 +- docs/architecture.md | 53 +- docs/conventions/config.md | 31 +- docs/conventions/logging.md | 12 +- docs/conventions/web-ui.md | 14 +- docs/database.md | 49 +- docs/passport.md | 28 +- docs/review.md | 39 +- docs/security.md | 159 ++-- go.mod | 2 +- internal/adapter/repo/pocketbase/identity.go | 264 ++++++ .../adapter/repo/pocketbase/identity_test.go | 316 +++++++ .../202608220001_trusted_header_login.go | 125 +++ .../repo/pocketbase/migrations/migrations.go | 1 + internal/adapter/repo/pocketbase/panel.go | 45 + internal/adapter/repo/pocketbase/provider.go | 72 -- .../adapter/repo/pocketbase/schema_test.go | 29 + internal/config/config.go | 114 +-- internal/config/config_test.go | 102 +-- internal/controller/http/app.go | 15 +- internal/controller/http/auth.go | 382 -------- internal/controller/http/auth_test.go | 834 ++++++++++-------- internal/controller/http/errors.go | 18 +- internal/controller/http/identity.go | 246 ++++++ internal/controller/http/list_test.go | 2 +- internal/controller/http/login_test.go | 358 -------- internal/controller/http/ownership_test.go | 61 +- internal/controller/http/rate_limit.go | 34 + internal/controller/http/session.go | 72 -- internal/controller/http/status_test.go | 3 +- internal/controller/http/transcribe_test.go | 79 +- internal/controller/http/webapp.go | 15 +- internal/controller/http/webapp_test.go | 22 +- internal/service/pipeline_test.go | 3 + .../.openspec.yaml | 2 + .../2026-08-22-trusted-header-login/design.md | 361 ++++++++ .../proposal.md | 71 ++ .../review/report.md | 511 +++++++++++ .../specs/access/spec.md | 674 ++++++++++++++ .../specs/archive/spec.md | 100 +++ .../specs/intake/spec.md | 111 +++ .../specs/storage/spec.md | 143 +++ .../specs/webapp/spec.md | 126 +++ .../2026-08-22-trusted-header-login/tasks.md | 206 +++++ openspec/specs/access/spec.md | 798 ++++++++++------- openspec/specs/archive/spec.md | 8 +- openspec/specs/intake/spec.md | 34 +- openspec/specs/storage/spec.md | 38 +- openspec/specs/webapp/spec.md | 95 +- web/src/api.ts | 25 +- web/src/screens/HomeScreen.test.ts | 45 +- web/src/screens/HomeScreen.vue | 18 +- 63 files changed, 5257 insertions(+), 2305 deletions(-) create mode 100644 cmd/devtools/main.go delete mode 100644 cmd/oidcstub/main.go create mode 100644 docs/adr/ADR-2026-08-22-login-by-trusted-header.md create mode 100644 internal/adapter/repo/pocketbase/identity.go create mode 100644 internal/adapter/repo/pocketbase/identity_test.go create mode 100644 internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go delete mode 100644 internal/adapter/repo/pocketbase/provider.go delete mode 100644 internal/controller/http/auth.go create mode 100644 internal/controller/http/identity.go delete mode 100644 internal/controller/http/login_test.go delete mode 100644 internal/controller/http/session.go create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/design.md create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/proposal.md create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/review/report.md create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/specs/access/spec.md create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/specs/archive/spec.md create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/specs/intake/spec.md create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/specs/webapp/spec.md create mode 100644 openspec/changes/archive/2026-08-22-trusted-header-login/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index 9daddfd..6cdeee9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/Dockerfile b/Dockerfile index a765f5a..4129b82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 4d1e49e..9e914ef 100644 --- a/README.md +++ b/README.md @@ -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/ # Интерфейсы адаптеров и репозиториев, типы ошибок diff --git a/cmd/devtools/main.go b/cmd/devtools/main.go new file mode 100644 index 0000000..01b3ddb --- /dev/null +++ b/cmd/devtools/main.go @@ -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) + } +} diff --git a/cmd/oidcstub/main.go b/cmd/oidcstub/main.go deleted file mode 100644 index ee01cbc..0000000 --- a/cmd/oidcstub/main.go +++ /dev/null @@ -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", "", "Почта вошедшего; пустое значение даёт @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) - } -} diff --git a/cmd/transcriber/journal_route_test.go b/cmd/transcriber/journal_route_test.go index bd43ff0..233056b 100644 --- a/cmd/transcriber/journal_route_test.go +++ b/cmd/transcriber/journal_route_test.go @@ -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}, } diff --git a/cmd/transcriber/main.go b/cmd/transcriber/main.go index 7898cae..5afb81e 100644 --- a/cmd/transcriber/main.go +++ b/cmd/transcriber/main.go @@ -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) // Раздача приложения вешается последней: она занимает корень, и всё, diff --git a/config.example.toml b/config.example.toml index 45acdd9..e4f7bdf 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 "Второй" diff --git a/docs/adr/ADR-2026-08-12-oidc-exchange-via-own-route.md b/docs/adr/ADR-2026-08-12-oidc-exchange-via-own-route.md index 786004d..e5430be 100644 --- a/docs/adr/ADR-2026-08-12-oidc-exchange-via-own-route.md +++ b/docs/adr/ADR-2026-08-12-oidc-exchange-via-own-route.md @@ -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) ## Решение diff --git a/docs/adr/ADR-2026-08-12-session-without-refresh.md b/docs/adr/ADR-2026-08-12-session-without-refresh.md index f1852e9..96b0644 100644 --- a/docs/adr/ADR-2026-08-12-session-without-refresh.md +++ b/docs/adr/ADR-2026-08-12-session-without-refresh.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) ## Решение diff --git a/docs/adr/ADR-2026-08-22-login-by-trusted-header.md b/docs/adr/ADR-2026-08-22-login-by-trusted-header.md new file mode 100644 index 0000000..c989065 --- /dev/null +++ b/docs/adr/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 на домен заведут, сервис не узнает никого. diff --git a/docs/adr/README.md b/docs/adr/README.md index fe2bf29..ec1bc47 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) | diff --git a/docs/architecture.md b/docs/architecture.md index bf05e74..61172a1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 не закрывает: у неё свой пароль diff --git a/docs/conventions/config.md b/docs/conventions/config.md index 89f1217..313cb07 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -57,16 +57,17 @@ force_shutdown_timeout = # ждать остановки ворке Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»). -*Расхождение:* адреса провайдера в секции `[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` в журнал попасть не должно. +процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с +пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об +этом было бы неоткуда — все адреса приложения просто отвечали бы отказом. +Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в +силе для прочих секций, где секреты есть. ## Структура в коде diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index 674f355..70beeff 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -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` | diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index 34a11ce..25bcbfc 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -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). ## Показ ошибок и состояний diff --git a/docs/database.md b/docs/database.md index 23e63a4..8d83ca8 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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 вместе с собственным входом: срок жизни сессии, +потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся +вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом +запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться. **У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок diff --git a/docs/passport.md b/docs/passport.md index 18af77c..f7ec0f0 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -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. diff --git a/docs/review.md b/docs/review.md index e970a6f..1c59f6a 100644 --- a/docs/review.md +++ b/docs/review.md @@ -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 по-прежнему недоступна — её правило на домен живёт в + контуре (см. «Не проверит ни один проход»). ## Журнал дефектов diff --git a/docs/security.md b/docs/security.md index 98fd496..5993495 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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` поимённо), а + слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял + её у себя в чужой сети, отвечает за это сам. - **Злоупотребление со стороны пользователя из белого списка.** Приглашённому доверяем полностью. - **Достоверность расшифровки.** Подмена или искажение текста на стороне diff --git a/go.mod b/go.mod index dfe7c85..b0020d6 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/adapter/repo/pocketbase/identity.go b/internal/adapter/repo/pocketbase/identity.go new file mode 100644 index 0000000..44857b6 --- /dev/null +++ b/internal/adapter/repo/pocketbase/identity.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/identity_test.go b/internal/adapter/repo/pocketbase/identity_test.go new file mode 100644 index 0000000..0cf483d --- /dev/null +++ b/internal/adapter/repo/pocketbase/identity_test.go @@ -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)) +} diff --git a/internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go b/internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go new file mode 100644 index 0000000..d51bf52 --- /dev/null +++ b/internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/migrations/migrations.go b/internal/adapter/repo/pocketbase/migrations/migrations.go index 0a34c94..187e795 100644 --- a/internal/adapter/repo/pocketbase/migrations/migrations.go +++ b/internal/adapter/repo/pocketbase/migrations/migrations.go @@ -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 } diff --git a/internal/adapter/repo/pocketbase/panel.go b/internal/adapter/repo/pocketbase/panel.go index 0e2183f..cf47e65 100644 --- a/internal/adapter/repo/pocketbase/panel.go +++ b/internal/adapter/repo/pocketbase/panel.go @@ -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() + }) +} diff --git a/internal/adapter/repo/pocketbase/provider.go b/internal/adapter/repo/pocketbase/provider.go deleted file mode 100644 index 10e3061..0000000 --- a/internal/adapter/repo/pocketbase/provider.go +++ /dev/null @@ -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 -} diff --git a/internal/adapter/repo/pocketbase/schema_test.go b/internal/adapter/repo/pocketbase/schema_test.go index 2c6afef..9129517 100644 --- a/internal/adapter/repo/pocketbase/schema_test.go +++ b/internal/adapter/repo/pocketbase/schema_test.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 5ea3bb9..bdefaec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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{}, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ad5ffca..8e782b1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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() - if err == nil { - t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key) - } - if !strings.Contains(err.Error(), key) { - t.Fatalf("имя ключа %s не названо: %v", key, err) - } - }) - } -} - -// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал, -// и значения секрета в нём быть не должно — только имя ключа. -func TestAuthConfigValidateHidesSecretValue(t *testing.T) { - cfg := validAuthConfig() - cfg.ClientSecret = "super-secret-value" - cfg.AuthURL = "" - - err := cfg.Validate() +// Пустой перечень значит «не верить никому»: сервис поднялся бы, никого не +// узнавая, и молчать об этом старт не вправе. +func TestAuthConfigValidateRejectsEmptyList(t *testing.T) { + err := AuthConfig{}.Validate() if err == nil { - t.Fatal("отказа нет") + t.Fatal("пустой перечень принят: сервис поднялся бы никого не узнающим") } - if strings.Contains(err.Error(), "super-secret-value") { - t.Fatalf("значение секрета попало в текст отказа: %v", err) + if !strings.Contains(err.Error(), "trusted_proxies") { + 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 +// Нечитаемая строка роняет старт: перечень с опечаткой проверяется только тем, +// что кто-то не смог войти. +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.Fatalf("негодный адрес %q пропущен", value) + 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() diff --git a/internal/controller/http/app.go b/internal/controller/http/app.go index 1f0a78f..313360f 100644 --- a/internal/controller/http/app.go +++ b/internal/controller/http/app.go @@ -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"), diff --git a/internal/controller/http/auth.go b/internal/controller/http/auth.go deleted file mode 100644 index e296530..0000000 --- a/internal/controller/http/auth.go +++ /dev/null @@ -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, - }) -} diff --git a/internal/controller/http/auth_test.go b/internal/controller/http/auth_test.go index 242ec62..8d6f4c1 100644 --- a/internal/controller/http/auth_test.go +++ b/internal/controller/http/auth_test.go @@ -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") - require.NoError(t, err) - assert.Len(t, accountsAfter, len(accountsBefore)) - - // Носитель убирается и на отказном возврате: иначе состояние - // осталось бы годным для новой попытки. - assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;") - }) - } + records, err := app.FindAllRecords(migrations.UsersCollection) + require.NoError(t, err) + 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(), "приложение") } diff --git a/internal/controller/http/errors.go b/internal/controller/http/errors.go index 88e6072..5bb28dd 100644 --- a/internal/controller/http/errors.go +++ b/internal/controller/http/errors.go @@ -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) diff --git a/internal/controller/http/identity.go b/internal/controller/http/identity.go new file mode 100644 index 0000000..f3ca5bd --- /dev/null +++ b/internal/controller/http/identity.go @@ -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 +} diff --git a/internal/controller/http/list_test.go b/internal/controller/http/list_test.go index 588ef35..282bd23 100644 --- a/internal/controller/http/list_test.go +++ b/internal/controller/http/list_test.go @@ -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) diff --git a/internal/controller/http/login_test.go b/internal/controller/http/login_test.go deleted file mode 100644 index 5398067..0000000 --- a/internal/controller/http/login_test.go +++ /dev/null @@ -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") -} diff --git a/internal/controller/http/ownership_test.go b/internal/controller/http/ownership_test.go index 7bb0df9..b272414 100644 --- a/internal/controller/http/ownership_test.go +++ b/internal/controller/http/ownership_test.go @@ -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"` diff --git a/internal/controller/http/rate_limit.go b/internal/controller/http/rate_limit.go index 30fd4f3..8412463 100644 --- a/internal/controller/http/rate_limit.go +++ b/internal/controller/http/rate_limit.go @@ -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 +} diff --git a/internal/controller/http/session.go b/internal/controller/http/session.go deleted file mode 100644 index 56a3533..0000000 --- a/internal/controller/http/session.go +++ /dev/null @@ -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() - }, - } -} diff --git a/internal/controller/http/status_test.go b/internal/controller/http/status_test.go index e00b2a4..3b6eb14 100644 --- a/internal/controller/http/status_test.go +++ b/internal/controller/http/status_test.go @@ -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) diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index fdfc446..64cfb02 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -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, } } diff --git a/internal/controller/http/webapp.go b/internal/controller/http/webapp.go index a60eeec..1021db7 100644 --- a/internal/controller/http/webapp.go +++ b/internal/controller/http/webapp.go @@ -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)}, } diff --git a/internal/controller/http/webapp_test.go b/internal/controller/http/webapp_test.go index 60af3e8..b87a053 100644 --- a/internal/controller/http/webapp_test.go +++ b/internal/controller/http/webapp_test.go @@ -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) diff --git a/internal/service/pipeline_test.go b/internal/service/pipeline_test.go index 607ea0c..c33a866 100644 --- a/internal/service/pipeline_test.go +++ b/internal/service/pipeline_test.go @@ -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()) diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/.openspec.yaml b/openspec/changes/archive/2026-08-22-trusted-header-login/.openspec.yaml new file mode 100644 index 0000000..6529e83 --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/design.md b/openspec/changes/archive/2026-08-22-trusted-header-login/design.md new file mode 100644 index 0000000..d7c970c --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/design.md @@ -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). diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/proposal.md b/openspec/changes/archive/2026-08-22-trusted-header-login/proposal.md new file mode 100644 index 0000000..ecfedb9 --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/proposal.md @@ -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`. Этой работой они не проверяются, но + требование к ним она обязана назвать. diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/review/report.md b/openspec/changes/archive/2026-08-22-trusted-header-login/review/report.md new file mode 100644 index 0000000..1ee496e --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/review/report.md @@ -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 ./...`. Код изменения не правился. diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/specs/access/spec.md b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/access/spec.md new file mode 100644 index 0000000..7476ba3 --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/access/spec.md @@ -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**: Настройку узнавания нормирует требование «Доверенный источник +объявлен настройкой»; проверка целостности настройки на старте сохраняется там. diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/specs/archive/spec.md b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/archive/spec.md new file mode 100644 index 0000000..d15cb26 --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/archive/spec.md @@ -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** ни файла, ни аудиозаписи не заводится + diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/specs/intake/spec.md b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/intake/spec.md new file mode 100644 index 0000000..4b4fd85 --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/intake/spec.md @@ -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`: собственного порога по размеру у приёма нет + diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/specs/storage/spec.md b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/storage/spec.md new file mode 100644 index 0000000..78b1719 --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/storage/spec.md @@ -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** шаг завершается без отказа + diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/specs/webapp/spec.md b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/webapp/spec.md new file mode 100644 index 0000000..f9db09d --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/specs/webapp/spec.md @@ -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** эта строка не та, которой оно сообщает о неузнавании diff --git a/openspec/changes/archive/2026-08-22-trusted-header-login/tasks.md b/openspec/changes/archive/2026-08-22-trusted-header-login/tasks.md new file mode 100644 index 0000000..38e1ae1 --- /dev/null +++ b/openspec/changes/archive/2026-08-22-trusted-header-login/tasks.md @@ -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 Поведенческая проверка: сервис поднят локально, приложение открывается, + учётная запись заводится первым обращением, запрос без заголовка получает + отказ. diff --git a/openspec/specs/access/spec.md b/openspec/specs/access/spec.md index 0c56f34..8be916b 100644 --- a/openspec/specs/access/spec.md +++ b/openspec/specs/access/spec.md @@ -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** журнал подъёма называет доверенные адреса + diff --git a/openspec/specs/archive/spec.md b/openspec/specs/archive/spec.md index ab4b84b..aabcd74 100644 --- a/openspec/specs/archive/spec.md +++ b/openspec/specs/archive/spec.md @@ -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** ни файла, ни аудиозаписи не заводится diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index 44d3ff2..218c8d5 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -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`: собственного порога по размеру у приёма нет diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index 60360b1..8c07ac5 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -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: Свой файл отдаётся diff --git a/openspec/specs/webapp/spec.md b/openspec/specs/webapp/spec.md index fa3ad7d..760d466 100644 --- a/openspec/specs/webapp/spec.md +++ b/openspec/specs/webapp/spec.md @@ -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** адреса почты на экране нет diff --git a/web/src/api.ts b/web/src/api.ts index 2f2eff9..be1f53e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -17,14 +17,15 @@ interface ErrorBody { } /** - * Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи: - * экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт, - * иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение → - * вход → приложение». + * Исход запроса. «Сервис вас не узнал» — свой исход, а не разновидность + * неудачи: человек по нему делает не то, что по «сервис не отвечает». Прежде + * этот исход уводил ко входу; уводить стало некуда — своего входа у сервиса + * нет, а заголовок ставит обратный прокси, — и различие сохраняется ради + * текста, а не ради перехода. */ export type ApiResult = | { status: 'ok'; value: T } - | { status: 'unauthorized' } + | { status: 'unrecognized'; message: string } | { status: 'failed'; message: string } const noConnectionMessage = 'Связи нет' @@ -54,7 +55,11 @@ async function request(path: string): Promise> { } 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(path: string): Promise> { } } -/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */ +/** + * Спрашивает сервис, кто пришёл. Заголовок ставит обратный прокси, и приложение + * его не видит вовсе — этот адрес единственный способ узнать вошедшего. + */ export function fetchMe(): Promise> { return request('/me') } - -/** Адрес, которым сервис уводит человека ко входу. */ -export const loginPath = '/auth/login' diff --git a/web/src/screens/HomeScreen.test.ts b/web/src/screens/HomeScreen.test.ts index b18f91a..fd70027 100644 --- a/web/src/screens/HomeScreen.test.ts +++ b/web/src/screens/HomeScreen.test.ts @@ -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({}) }) + it('показывает текст сервера, когда тот не узнал пришедшего, и никуда не уводит', async () => { + answerWith({ + ok: false, + status: 401, + json: () => Promise.resolve({ error_code: 'unauthorized', message: 'Сервис вас не узнал' }), + }) - mount(HomeScreen) + const screen = mount(HomeScreen) await flushPromises() - expect(assign).toHaveBeenCalledWith('/auth/login') + expect(screen.text()).toContain('Сервис вас не узнал') + expect(assign).not.toHaveBeenCalled() }) - it('на прочий отказ показывает строку и ко входу не ведёт', async () => { + 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, diff --git a/web/src/screens/HomeScreen.vue b/web/src/screens/HomeScreen.vue index 7600763..8a5c31b 100644 --- a/web/src/screens/HomeScreen.vue +++ b/web/src/screens/HomeScreen.vue @@ -1,19 +1,23 @@