вход переехал на доверенный заголовок Authelia вместо собственного OIDC

- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
av
2026-08-22 20:24:22 +03:00
parent e4441f3c49
commit 7f33c957e5
63 changed files with 5257 additions and 2305 deletions
+25 -18
View File
@@ -36,15 +36,14 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Что нарушать нельзя.
- **Секрет не покидает конфиг.** Ключ SpeechKit, пара ключей Object
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
- **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
вручную во всех местах выкладки. **critical**
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
пользователей хранилища — туда его кладёт приведение настроек при каждом
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
места запрета это не отменяет.
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
настройках коллекции пользователей хранилища,и снято 2026-08-22 вместе с
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
на что. Чтение файла базы больше не равносильно чтению секрета.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
@@ -120,7 +119,7 @@ go vet ./...
gofmt -l .
golangci-lint run
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
go run ./cmd/oidcstub # подставной провайдер OIDC для локального входа
go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально
task front # приложение: зависимости, Biome, юнит-тесты, сборка
task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом
@@ -211,9 +210,16 @@ Node на машину **не ставится**: шаг сборки прило
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
Списывать отказ на долг больше нельзя: списывать не на что.
Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
новый:
- `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id
may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`,
а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением
владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`.
Владельца файлов в смонтированном каталоге это не двигает, потому что те же
числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`);
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
сравнения ошибок приведением типа. Закрыто задачей
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
@@ -230,17 +236,18 @@ Node на машину **не ставится**: шаг сборки прило
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
его ронять и пересоздавать можно свободно.
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
проверяются на старте, но наружу при этом не обращаются, так что годятся
выдуманные непустые значения — адреса `[auth]` должны лишь разбираться как
ссылки. Расшифровка при выдуманных ключах не работает: её подменяют
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
`internal/adapter/recognizer/memory.go`. Подробности строками в
`config.example.toml`.
**Войти при выдуманных адресах нельзя** — они никуда не ведут, а сервис без
входа не отдаёт ничего. Вместо провайдера поднимается заглушка
`cmd/oidcstub` — она отвечает на `/authorize`, `/token` и `/userinfo`, а
проверок не делает никаких;
значения `[auth]` под неё стоят строками в `config.example.toml`. Ключи
боевого провайдера на машине разработчика при этом по-прежнему не нужны.
**На машине без прокси представиться нечем**: сервис узнаёт
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
не ставит. На место контура встаёт подставной прокси —
`go run ./cmd/devtools proxy`: он слушает свой порт, ставит заголовок и
переправляет запрос сервису, а проверок не делает никаких. Приложение при этом
открывают **по адресу прокси**, а не по адресу сервиса. Ключей боевого
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`.
+8 -3
View File
@@ -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
+19 -12
View File
@@ -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/ # Интерфейсы адаптеров и репозиториев, типы ошибок
+126
View File
@@ -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)
}
}
-178
View File
@@ -1,178 +0,0 @@
// Команда oidcstub — подставной провайдер OIDC для локального запуска.
//
// Сервис держит вход через внешнего провайдера, и без него в приложение не
// попасть вовсе: секция `[auth]` обязательна, а выдуманные адреса разбираются
// как ссылки, но никуда не ведут. Настоящая Authelia — отдельный сервис, который
// надо развернуть и настроить, а ключи боевого провайдера на машине разработчика
// лежать не должны.
//
// Заглушка отвечает на `/authorize`, `/token` и `/userinfo`, и этого хватает:
// `user_info_url` — обязательный ключ конфига, а при заполненном адресе сведений
// библиотека хранилища берёт их обычным запросом с предъявленным токеном и
// `id_token` не смотрит вовсе. Поэтому здесь нет ни ключей подписи, ни документа
// обнаружения.
//
// Проверок она не делает никаких — ни секрета клиента, ни проверочного кода
// PKCE, ни выданного токена. Вход у неё один и заранее известный: она нужна,
// чтобы дойти до куки сессии, а не чтобы изображать провайдера. По той же
// причине слушает она только петлевой адрес.
//
// Своих тестов у заглушки нет, и это решение владельца от 2026-08-15, а не
// недосмотр. Разбор `redirect_uri` и выбор метода машиной поэтому не
// проверяются: регресс в них ловится руками, на первом же локальном входе.
// Довод — цена отказа: в образ этот пакет не едет, читатель у него один, а сам
// отказ громкий и виден сразу. Путь входа при этом проверен и без неё — своим
// подставным провайдером в `internal/controller/http/login_test.go`.
//
// Запуск:
//
// go run ./cmd/oidcstub
// go run ./cmd/oidcstub -sub local-2
//
// Другое значение `-sub` заводит второго вошедшего, и почта переезжает вместе с
// ним: без своей почты второй вход отвергается вовсе. Хранилище ищет учётную
// запись сперва по признаку провайдера, а не найдя — по адресу почты, и на
// найденной записи признак уникален. Второй `sub` при общей почте пришёл бы к той
// же записи и получил бы отказ уникальности — вход отвечал бы 401, а причина
// осталась бы в журнале хранилища строкой про связь.
package main
import (
"encoding/json"
"flag"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"time"
)
// authCode — код, который заглушка отдаёт на возврате. Значение постоянное:
// одноразовость кода держит провайдер, а здесь её изображать не для кого.
const authCode = "local-code"
// accessToken — то, что уезжает в обмен и приходит обратно заголовком запроса
// сведений о вошедшем. Заглушка его не сверяет.
const accessToken = "local-token"
// readHeaderTimeout — потолок чтения заголовков. Заглушка стоит на петлевом
// адресе, но сервер без единого срока держит подвисшее соединение вечно.
const readHeaderTimeout = 5 * time.Second
func main() {
port := flag.Int("port", 9000, "Порт заглушки")
sub := flag.String("sub", "local-1", "Идентификатор вошедшего: разные значения дают разных владельцев записей")
name := flag.String("name", "Локальный", "Имя вошедшего")
email := flag.String("email", "", "Почта вошедшего; пустое значение даёт <sub>@example.com")
flag.Parse()
// Почта выводится из `sub`, а не стоит своим умолчанием: общая почта у двух
// разных `sub` приводит второй вход к первой записи, а признак провайдера на
// ней уникален — второй вошедший получал бы 401 вместо своей учётной записи.
// Обещание «разные `-sub` дают разных владельцев» держится этой строкой.
if *email == "" {
*email = *sub + "@example.com"
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
mux := http.NewServeMux()
mux.HandleFunc("/authorize", authorize(logger))
mux.HandleFunc("/token", token(logger))
mux.HandleFunc("/userinfo", userInfo(logger, *sub, *name, *email))
// Петлевой адрес, а не все — заглушка пускает кого угодно кем угодно, и в
// чужой сети это дыра, а не удобство.
addr := fmt.Sprintf("127.0.0.1:%d", *port)
server := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: readHeaderTimeout,
}
logger.Info("oidc stub started", "addr", addr, "sub", *sub)
if err := server.ListenAndServe(); err != nil {
logger.Error("oidc stub stopped", "error", err)
os.Exit(1)
}
}
// authorize уводит браузер обратно на адрес возврата с готовым кодом.
//
// Настоящий провайдер спросил бы здесь имя и пароль; заглушка не спрашивает
// ничего и возвращает сразу — в этом вся её работа.
func authorize(logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
target, err := url.Parse(query.Get("redirect_uri"))
if err != nil || target.Host == "" {
logger.Error("authorize rejected", "reason", "redirect_uri is missing or malformed")
http.Error(w, "redirect_uri не задан или не разбирается", http.StatusBadRequest)
return
}
// Состояние возвращается тем же значением, каким пришло: сервис сверяет
// его со своей кукой и без совпадения отвергает возврат.
back := target.Query()
back.Set("code", authCode)
if state := query.Get("state"); state != "" {
back.Set("state", state)
}
target.RawQuery = back.Encode()
logger.Info("authorize passed", "redirect_host", target.Host)
http.Redirect(w, r, target.String(), http.StatusFound)
}
}
// token отдаёт токен в обмен на код. Ни код, ни секрет клиента, ни проверочный
// код PKCE не сверяются.
func token(logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "обмен идёт методом POST", http.StatusMethodNotAllowed)
return
}
// `id_token` не отдаётся намеренно: подписать его нечем, а библиотека
// хранилища его и не смотрит — `user_info_url` стоит в перечне
// обязательных ключей конфига, и без него сервис не поднимается вовсе.
writeJSON(logger, w, map[string]any{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
})
}
}
// userInfo отдаёт сведения о вошедшем.
//
// `email_verified` обязано быть истинным: без него библиотека адрес почты в
// учётную запись не запишет, и запись заведётся без него.
func userInfo(logger *slog.Logger, sub, name, email string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
writeJSON(logger, w, map[string]any{
"sub": sub,
"name": name,
"preferred_username": sub,
"email": email,
"email_verified": true,
})
}
}
// writeJSON отвечает разметкой JSON. Отказ записи идёт в журнал: ответ к этому
// моменту уже начат, и сказать о нём спрашивающему нечем.
func writeJSON(logger *slog.Logger, w http.ResponseWriter, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(body); err != nil {
logger.Error("response write failed", "error", err)
}
}
-1
View File
@@ -16,7 +16,6 @@ func journalMounts() []httpcontroller.Mount {
{Path: httpcontroller.StorageRoot},
{Path: httpcontroller.PanelRoot},
{Path: httpcontroller.AppRoot},
{Path: httpcontroller.AuthRoot},
{Path: httpcontroller.HealthPath, Exact: true},
{Path: httpcontroller.MetricsPath, Exact: true},
}
+30 -22
View File
@@ -53,13 +53,26 @@ func main() {
logger.Info("Configuration loaded successfully", "config_path", *configPath)
}
// Незаполненный вход роняет старт: подняться с молча выключенным входом
// значит остаться открытым наружу, и узнать об этом было бы неоткуда.
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
// этом было бы неоткуда.
if err := cfg.Auth.Validate(); err != nil {
logger.Error("Unable to start with incomplete login settings", "error", err)
logger.Error("Unable to start without trusted proxies", "error", err)
os.Exit(1)
}
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
// значило бы платить за настройку, которая не меняется.
trustedNetworks, err := cfg.Auth.TrustedNetworks()
if err != nil {
logger.Error("Unable to read trusted proxies", "error", err)
os.Exit(1)
}
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
// доходит вовсе, — а это разные поломки в разных местах.
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом.
@@ -161,18 +174,12 @@ func main() {
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда.
appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger)
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
AuthURL: cfg.Auth.AuthURL,
RedirectURL: cfg.Auth.RedirectURL,
ClientID: cfg.Auth.ClientID,
SecureCookie: cfg.Auth.SecureCookie,
}, logger)
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
// наблюдения и правило неизвестного пути у раздачи приложения.
mounts := httpcontroller.ServiceMounts(appHandler, authHandler, promhttp.Handler())
mounts := httpcontroller.ServiceMounts(appHandler, promhttp.Handler())
dist, appBuilt := web.Dist()
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger)
@@ -223,18 +230,12 @@ func main() {
return err
})
// Настройки провайдера приводятся к конфигу при каждом подъёме:
// применённый шаг схемы не переписывается, и секрет, положенный
// однажды шагом, не пережил бы ротации.
if err := pbrepo.ApplyProviderSettings(storage, pbrepo.ProviderSettings{
AuthURL: cfg.Auth.AuthURL,
TokenURL: cfg.Auth.TokenURL,
UserInfoURL: cfg.Auth.UserInfoURL,
ClientID: cfg.Auth.ClientID,
ClientSecret: cfg.Auth.ClientSecret,
}); err != nil {
return fmt.Errorf("failed to apply provider settings: %w", err)
}
// Слой узнавания вешается на **корневой** роутер, а не под корнем
// приложения: он накрывает ещё и адрес выдачи файлового токена, который
// принадлежит роутеру хранилища и группой не накрывается. Область его
// действия при этом выводится из перечня адресного пространства — см.
// `underIdentifiedArea`, — а не из места привязки.
se.Router.Bind(httpcontroller.TrustedHeaderIdentity(storage, mounts, trustedNetworks, logger))
// Своё правило ограничителя частоты под корень приложения. Правило
// хранилища настроено на его собственный корень и наших адресов больше не
@@ -244,6 +245,13 @@ func main() {
return fmt.Errorf("failed to apply app rate limit: %w", err)
}
// Хранилищу называется заголовок, из которого брать адрес
// спрашивающего. Без этого счётчик ограничителя ключуется адресом пира,
// а пир теперь всегда один — прокси, и бюджет становится общим на всех.
if err := httpcontroller.ApplyTrustedProxyHeaders(storage); err != nil {
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
}
httpcontroller.RegisterServiceRoutes(se.Router, mounts)
// Раздача приложения вешается последней: она занимает корень, и всё,
+31 -44
View File
@@ -60,56 +60,43 @@ object_storage_region = "ru-central1"
# Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/"
# Вход через внешнего провайдера OIDC (Authelia).
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
# API открытым наружу.
# Кому сервис верит на входе.
#
# Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком
# `Remote-User`, сходив к Authelia. Здесь остаётся один ключ — перечень адресов,
# чьему заголовку верить. Пустой перечень роняет старт: он значит «не верить
# никому», то есть сервис, поднявшийся никого не узнающим.
[auth]
# Адрес, куда сервис уводит человека на вход
auth_url = "https://auth.example.com/api/oidc/authorization"
# Адрес, где код обменивается на токен
token_url = "https://auth.example.com/api/oidc/token"
# Адрес, откуда берутся сведения о вошедшем
user_info_url = "https://auth.example.com/api/oidc/userinfo"
# Идентификатор клиента, заведённого у провайдера
client_id = "transcriber"
# Секрет клиента; приходит из выкладки, в git не коммитится
client_secret = ""
# Адрес возврата; тот же, что записан клиенту у провайдера
redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true
# Адреса и подсети, с которых приходит обратный прокси. Сверяется адрес самого
# соединения, а не пересылаемый заголовок: пересылаемым распоряжается тот, кто
# шлёт запрос.
#
# **Перечень задаёт адрес прокси, а не весь частный диапазон.** Всякий, кто
# дотянулся до сервиса с адреса из этого перечня, называет себя кем угодно и
# получает чужой архив; `172.16.0.0/12` означало бы «любой контейнер на хосте»,
# включая чужие проекты. На сервере сюда ставят адрес сети, в которой стоит
# Caddy, — узкий и свой.
trusted_proxies = ["172.20.0.0/24"]
# Локальный вход без Authelia.
#
# Провайдер на машине разработчика не поднимается, а ключи боевого на ней лежать
# не должны — вместо провайдера идёт заглушка `cmd/oidcstub`. Она отвечает на
# `/authorize`, `/token` и `/userinfo` и не сверяет ни секрет клиента, ни
# проверочный код PKCE: вход у неё один и заранее известный. Поднимается
# отдельным процессом:
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
# приложение локально не открылось бы вовсе. На место контура встаёт подставной
# прокси из оснастки: он слушает свой порт, ставит заголовок и переправляет
# запрос сервису. Проверок он не делает никаких — ни пароля, ни группы, ни
# срока, — и это его назначение, а не упущение.
#
# go run ./cmd/oidcstub
# go run ./cmd/devtools proxy
#
# Замени секцию `[auth]` выше на эти значения — вход пойдёт настоящим путём,
# вплоть до куки сессии и заведённой учётной записи в панели:
# Приложение после этого открывают по адресу прокси — http://localhost:9000, —
# а не по адресу сервиса: запрос мимо прокси приходит без заголовка и никого не
# узнаёт.
#
# auth_url = "http://localhost:9000/authorize"
# token_url = "http://localhost:9000/token"
# user_info_url = "http://localhost:9000/userinfo"
# client_id = "transcriber"
# client_secret = "local"
# redirect_url = "http://localhost:8080/auth/callback"
# secure_cookie = false
# Под него в перечне выше должен стоять петлевой адрес:
#
# Другое значение `-sub` заводит второго вошедшего: это неизменяемый признак,
# по которому обмен ищет учётную запись. Почта переезжает вместе с ним сама —
# заглушка выводит её из `-sub`, — и это обязательно: при общей почте второй вход
# приходит к первой записи и получает отказ.
# trusted_proxies = ["127.0.0.1"]
#
# go run ./cmd/oidcstub -sub local-2
# Второй вошедший получается другим значением `-user`: логин у провайдера и есть
# ключ учётной записи.
#
# go run ./cmd/devtools proxy -user local-2 -name "Второй"
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
## Решение
@@ -5,6 +5,7 @@
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 6
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
## Решение
@@ -0,0 +1,73 @@
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
- **Дата:** 2026-08-22
- **Источник:** openspec/changes/archive/trusted-header-login/design.md
## Решение
Сервис перестаёт вести вход сам. Кто пришёл, он узнаёт из заголовка
`Remote-User`, поставленного обратным прокси, который сходил к Authelia;
заголовку верят только с адреса из объявленного перечня, а адрес берётся у
самого соединения. Учётная запись заводится первым обращением с новым логином и
находится по нему же дальше.
Убраны целиком: корень `/auth` с тремя адресами, куки `transcriber_session` и
`transcriber_login`, сверка состояния и проверочный код PKCE, обмен кода
внутрипроцессным запросом к роутеру хранилища, слои предъявления куки и запрета
продления, приведение настроек провайдера к конфигу, секрет клиента и срок жизни
сессии.
## Почему
Цитата из источника, раздел Р3:
> Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
> заголовку, который прокси поставил, сходив к Authelia.
>
> Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
> значение, живущее семь суток, отозванный у провайдера человек работал до
> истечения этого значения, и другого канала отзыва не было.
Оттуда же, Р1 — почему доверие судится адресом соединения, а не пересылаемым
заголовком:
> **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
> запрос. Барьер, который подделывается той же строкой, что и обходится, не
> барьер вовсе.
Отвергнут промежуточный вариант — заголовок как вход, сессия хранилища как
продолжение (Р3):
> Дешевле в работе (слой срабатывал бы раз в неделю, а не на каждом запросе), но
> возвращает ровно то, что задача убирает: значение, переживающее отзыв. Семь
> суток вернулись бы вместе с ним.
Контур к решению был готов заранее: обратный прокси уже отдавал `Remote-*` трём
соседним сервисам того же контура, а правила для этого сервиса там не было
вовсе — он не выложен.
## Последствия
- `+` Отзыв доступа действует со следующего запроса, а не через семь суток:
Authelia судит каждое обращение.
- `+` Секрет клиента исчез из конфига и из базы. Изъятие из инварианта «Секрет не
покидает конфиг» снято: чтение файла базы больше не равносильно чтению
секрета.
- `+` Своего протокола входа у сервиса не осталось — вместе с ним исчезли пять
накопившихся задач о его механике.
- `+` Панель закрывается доменом, а не правилом на литерал пути; обход подменой
знака перестаёт существовать.
- `` **Весь барьер держится на настройке прокси.** Прокси, добавляющий заголовок
вместо замены, открывает сервис любому под любым именем. Половину беды сервис
закрывает сам — запрос с двумя значениями заголовка не узнаёт никого, — вторую
проверить отсюда нечем: правило живёт в `pet-project-server`.
- `` **Логин у провайдера переиспользуем**, и новый его владелец получает архив
прежнего. Неизменяемого признака заголовок не приносит; не допускать
переиспользования — работа провайдера. Обратная сторона: переименование
заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни
убрать.
- `` Поиск учётной записи идёт на каждом запросе к области приложения вместо
раза в неделю. Уникальный индекс делает это одним обращением к базе; замера не
требовалось — сервисом пользуются единицы человек.
- `` Половина работы лежит вне репозитория: до того как правило прокси и правило
Authelia на домен заведут, сервис не узнает никого.
+3 -2
View File
@@ -35,6 +35,7 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
@@ -49,9 +50,9 @@
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
+38 -15
View File
@@ -15,7 +15,7 @@
[conventions/go-linters.md](conventions/go-linters.md).
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
входов**: приём за сессией, имя отправителя не доходит ни до
входов**: приём только от узнанного, имя отправителя не доходит ни до
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
наблюдатель видит единственный поднятый вход. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
@@ -51,9 +51,10 @@
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
его дальше: узнавание по заголовку доверенного источника, заведение учётной
записи первым обращением и то, какие адреса остаются открытыми. Собственный
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
записи не бывает вовсе — колонка владельца пустого значения не принимает.
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
@@ -150,6 +151,18 @@
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
выкладки `record-centric-model` откат образа работает, после — нет.
- **Откат образа через шаг схемы `202608220001` обрывает вход.** Шаг закрывает
правила коллекции пользователей наглухо, а прежний образ заводил учётную
запись внутренним запросом обмена кода — и этот запрос закрытое правило
отвергает. Проверено прогоном прежнего кода поверх нового каталога данных:
вход отвечает `401`, в журнале «storage rejected the exchange with code 403».
Порог тот же по форме, что и у `202608140002`: до выкладки
`trusted-header-login` откат работает, после — нет, и лечится он повторной
выкладкой вперёд. Обратного шага схемы нет и не планируется.
Окно этого порога сегодня пусто: сервис не выложен, а откат уже не работает с
шага `202608140002`. Строка стоит здесь потому, что порог принято называть
прямо, а не потому, что риск сегодня чем-то грозит.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»:
@@ -199,7 +212,8 @@
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути и уровень журнала |
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
| Узнавание предъявителя | `pbrepo.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод — `internal/controller/http.TrustedHeaderIdentity` |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
@@ -217,11 +231,20 @@
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
работает под непривилегированным пользователем `transcriber`.
работает под непривилегированным пользователем `transcriber` — в образе он
назван числом, `USER 1000:1000`, а не именем: имя разрешает в идентификатор сам
образ, и хост, которому надо понять владельца файлов в смонтированном каталоге,
разрешить его не может. Числа те же, что при заведении пользователя.
Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь
пакет: рядом в `cmd/` живёт `oidcstub`, подставной провайдер OIDC для локального
входа, и в образе ему делать нечего.
пакет: рядом в `cmd/` живёт `devtools`, оснастка разработчика, и в образе ей
делать нечего.
Оснастка лежит **одним** пакетом с подкомандами, а не пакетом на инструмент, и
это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки
здесь, «Деплой» в этом файле, «Команды» в памятке, `README`, — и забытая строка
сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
четыре места однажды, сколько бы подкоманд в нём ни завелось.
Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес:
у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому
@@ -237,13 +260,13 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
## Открытые вопросы
- **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер —
Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
[access](../openspec/specs/access/spec.md), решения
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
- **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
обратный прокси, сходив к Authelia; учётная запись заводится первым обращением
и находится по логину у провайдера. Задача `trusted-header-login` 2026-08-22.
Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа
судит провайдер на каждом запросе, а не однажды выданное значение. Норма
[access](../openspec/specs/access/spec.md), решение —
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
зависит возвращение убранного входа.
Панель администратора при этом Authelia не закрывает: у неё свой пароль
+17 -14
View File
@@ -57,16 +57,17 @@ force_shutdown_timeout = <N> # ждать остановки ворке
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
говорит, какой формы значение здесь ждут. Пустым оставлен только
`client_secret` — он и есть секрет.
*Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
входом.
Там же, комментарием под секцией, стоит **второй набор значений `[auth]` — под
подставной провайдер `cmd/oidcstub`**. Они — не живая форма, и это намеренно:
образец описывает боевую выкладку, а локальный вход — способ до неё дойти, и два
рабочих набора в одном файле читались бы как выбор без указания, какой из них
чей.
Там же, комментарием под секцией, стоит **второе значение перечня — петлевой
адрес, под подставной прокси `cmd/devtools proxy`**. Оно стоит закомментированным, и это
намеренно: образец описывает боевую выкладку, а локальный вход — способ до неё
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
какое из них чьё.
## Поля по дискриминатору `type`
@@ -95,7 +96,8 @@ Ansible из `pet-project-server`). Приложение просто читае
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
`yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
2026-08-22 вместе с собственным входом.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`).
- В `config.example.toml` секретные поля — пустые строки.
@@ -137,10 +139,11 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
`client_secret` в журнал попасть не должно.
процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
силе для прочих секций, где секреты есть.
## Структура в коде
+8 -4
View File
@@ -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` |
+9 -5
View File
@@ -75,8 +75,10 @@
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
`404`; путь внутри корня в приложение не проваливается никогда. Корней
сегодня четыре`/api/` у хранилища, `/app/` у приложения, `/auth/` у входа,
`/_/` у панели, — плюс `/health` и `/metrics` отдельными адресами. Приложение
сегодня три`/api/` у хранилища, `/app/` у приложения, `/_/` у панели, —
плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят
2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями
вне корней. Приложение
уехало из общего `/api/` решением владельца 2026-08-15: пространство
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
нашим. Перечень корней сервису не описывают, а из него **порождают**
@@ -104,9 +106,11 @@
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
не `Response`.
- **Сессия живёт кукой `transcriber_session`**, и приложение её не читает: кука
`HttpOnly`, браузер шлёт её сам, а вошедшего экран узнаёт по ответу API. Норма
— [access](../../openspec/specs/access/spec.md).
- **Сессии у сервиса нет вовсе**, и приложение не хранит ничего: кто пришёл,
называет заголовок обратного прокси, а приложение узнаёт его ответом API.
Кук сервис не ставит — это свойство сторожится проверкой. Норма —
[access](../../openspec/specs/access/spec.md), решение —
[ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md).
## Показ ошибок и состояний
+39 -10
View File
@@ -271,15 +271,33 @@ capability, и третий смысл развёл бы одно слово п
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
сужает: создание записи разрешено только контексту обмена OIDC
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
Без этого сужения закрытие API обходится двумя запросами — завести себе
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают.
`202608120001` выключил вход по паролю и одноразовый код; `202608220001`
довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление,
чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища
означает «только владелец панели».
Правку и удаление умолчание библиотеки открывало владельцу записи
(`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой
предъявления жил под корнем приложения, и браузер до поверхности хранилища не
дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи
лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого
имени. Наш код читает и заводит запись мимо правил, панель работает
суперпользователем, своих экранов профиля сервис не заводит.
- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом,
заведена шагом `202608220001`. В ней логин человека **у провайдера** — то
значение, которым его называет обратный прокси заголовком. По нему запись
ищется и по нему же заводится при первом обращении.
Почта в той же коллекции переведена в необязательную тем же шагом: провайдер
не обязан её приносить, а ключом она не служит. Уникальность почты держится
**частичным** индексом (`WHERE email != ''`), поэтому записи без почты
уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с
пустым ключом схема не примет вовсе.
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
@@ -324,6 +342,14 @@ capability, и третий смысл развёл бы одно слово п
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при
подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище
ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и
тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь
одновременно открытых карточек выбирают его целиком. Требование к контуру,
которое отсюда следует, записано в [security.md](security.md), «Периметр»:
`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать.
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
@@ -339,9 +365,12 @@ capability, и третий смысл развёл бы одно слово п
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Срок жизни сессии | нормирует [access](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки |
Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии,
потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся
вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом
запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться.
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
+15 -13
View File
@@ -21,7 +21,7 @@
| --- | --- |
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня не работает вовсе: домен целиком стоит за обратным прокси, и запрос программы отбивает он, не доходя до сервиса. Токен и правило прокси мимо входа приносит `api-tokens` |
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
@@ -61,10 +61,13 @@ Telegram.
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось
2026-08-11 вместе с решением про PocketBase: в панель администратора владелец
входит своим паролем, потому что подпустить к ней внешнего провайдера
PocketBase не даёт.
провайдер, свою регистрацию и свои пароли не делаем. Своя строка учётной
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
имя, названное провайдером, — заводит строку при первом обращении под новым
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
сервис не решает никогда. Одно исключение появилось 2026-08-11 вместе с
решением про PocketBase: в панель администратора владелец входит своим
паролем, потому что подпустить к ней внешнего провайдера PocketBase не даёт.
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
@@ -97,11 +100,10 @@ Telegram.
3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
токеном, получает идентификатор записи и читает её карточку
`GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только
предъявившему сессию OIDC: анонимный запрос всеми адресами отклоняется. Своего
входа у программы нет — его заводит `api-tokens`. Записи при этом
разграничены: программа с чужой сессией видит только записи того, чью сессию
предъявила.
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только тому, кого
назвал доверенный источник: неузнанный запрос всеми адресами отклоняется.
Своего способа представиться у программы нет — его заводит `api-tokens`.
Записи при этом разграничены: видны только записи того, чьим именем пришли.
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
получает признак остановки с причиной, и карточка записи отдаёт признак и
причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
@@ -119,6 +121,6 @@ Telegram.
задача `pocketbase-storage` 2026-08-12
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
держит хранилище, файлы и панель владельца. Схема и
раскладка — [database.md](database.md). Учётные записи она хранит и получает от
Authelia своим провайдером OIDC, но источником их не становится: заводит и
проверяет людей по-прежнему Authelia.
раскладка — [database.md](database.md). Учётные записи она хранит, а заводит их
сервис по имени, названному Authelia; источником людей она при этом не
становится: заводит и проверяет их по-прежнему Authelia.
+24 -15
View File
@@ -172,13 +172,15 @@
Форма: `<тема>: <вопрос> (<откуда>)`.
- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
в проверках. Подставных провайдера OIDC в репозитории теперь два — `cmd/oidcstub`
и `fakeProvider` в `internal/controller/http/login_test.go`, — с теми же
адресами и той же посылкой про `email_verified`, и они уже разошлись в мелочи
(`token_type` «bearer» против «Bearer»). Тем же вопросом судится подставной
распознаватель. Записанной конвенции о единственном доме подставных внешних
собеседников у проекта нет, поэтому спрашивать надо, а не считать нарушением
(ревью задачи про заглушку OIDC, 2026-08-15).
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте
`cmd/devtools proxy`, а проверки ставят заголовок сами и подставного собеседника
не держат вовсе. Тем же вопросом судится подставной распознаватель. Записанной
конвенции о единственном доме подставных внешних собеседников у проекта нет,
поэтому спрашивать надо, а не считать нарушением (ревью задачи про заглушку
OIDC, 2026-08-15).
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
внешнего собеседника и это держат правила `noctx` и `contextcheck`
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
@@ -294,13 +296,14 @@ API и имя не откатываются обратной правкой по
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы;
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
выкладки вне репозитория, и по коду её не проверить
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками — применение `SameSite`, приём
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
этого рода остаются гипотезами.
- `security`: поведение браузера с куками. Своих кук сервис больше не ставит
(2026-08-22), и класс сузился до кук, которые ставит панель хранилища; браузера
в прогоне нет, и находки этого рода остаются гипотезами.
**Перестали проверять сознательно:**
@@ -321,8 +324,14 @@ API и имя не откатываются обратной правкой по
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
тоже недоступен: сессию в прогоне выдать нечем.
остановку; нельзя — расшифровку и заливку.
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
сессию в прогоне выдать было нечем; теперь заголовок ставит `cmd/devtools
proxy`, и живьём проверяются узнавание, заведение учётной записи первым
обращением, отказ с недоверенного адреса и отказ старта на пустом перечне.
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
контуре (см. «Не проверит ни один проход»).
## Журнал дефектов
+106 -53
View File
@@ -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` поимённо), а
слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял
её у себя в чужой сети, отвечает за это сам.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
+1 -1
View File
@@ -13,6 +13,7 @@ require (
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/ozzo-validation/v4 v4.3.0
github.com/pocketbase/pocketbase v0.39.10
github.com/prometheus/client_golang v1.23.0
github.com/stretchr/testify v1.10.0
@@ -54,7 +55,6 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
@@ -0,0 +1,264 @@
package pocketbase
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// MaxProviderLoginLength — предел длины логина у провайдера.
//
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
// же, что у имени в умолчании библиотеки: длиннее имени логин не бывает, а два
// разных предела на соседних колонках одной записи разошлись бы молча.
const MaxProviderLoginLength = 255
// MaxDisplayNameLength — предел длины имени, пригодного к показу. Число то же и
// по той же причине: столько держит колонка имени в умолчании библиотеки.
const MaxDisplayNameLength = 255
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а
// негодный ввод, и звать по нему учётную запись не надо.
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
// Identity — то, чем доверенный источник называет пришедшего.
//
// Логин — ключ, остальное берётся только при заведении записи.
type Identity struct {
Login string
Name string
Email string
}
// EnsureUser находит учётную запись по логину у провайдера, а не найдя —
// заводит её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — придёт следующей задачей и возьмёт этот же
// метод; правило, уложенное куском в слой транспорта, пришлось бы тогда либо
// дублировать вторым куском, либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// Сравнение точное, знак в знак: приведение регистра завело бы правило, которого
// у провайдера нет, — считает ли он `admin` и `Admin` одним человеком, сервису
// неизвестно, а угаданное правило склеило бы двух разных людей.
func EnsureUser(app core.App, identity Identity) (record *core.Record, created bool, err error) {
login, ok := AcceptProviderLogin(identity.Login)
if !ok {
return nil, false, ErrLoginNotAcceptable
}
record, err = findUserByLogin(app, login)
if err != nil {
return nil, false, err
}
if record != nil {
return record, false, nil
}
users, err := findCollection(app, migrations.UsersCollection)
if err != nil {
return nil, false, err
}
record = core.NewRecord(users)
record.Set(migrations.ProviderLoginField, login)
// Имя и почта принимаются так же, как логин, а не кладутся как есть.
// Значения приходят заголовками, то есть задаются тем, кто шлёт запрос;
// имя длиннее предела колонки отвергается проверкой записи, и человек с
// таким именем у провайдера не завёлся бы **никогда** — каждый его запрос
// отвечал бы отказом сервиса. Негодное значение необязательного поля не
// вправе отменять заведение записи.
record.Set("name", acceptDisplayName(identity.Name))
if email, ok := acceptEmail(identity.Email); ok {
record.SetEmail(email)
}
// Пароль записи обязателен при любом значении признака — это проверка самой
// библиотеки, а не колонки. Ставится случайный: употребить его нельзя,
// потому что вход по паролю у коллекции выключен шагом схемы.
record.SetRandomPassword()
if err := app.Save(record); err != nil {
record, err = retryAfterConflict(app, login, record, err)
return record, record != nil, err
}
return record, true, nil
}
// retryAfterConflict разбирает отказ сохранения. Два отказа уникальности здесь
// разные, и исход у них разный.
//
// По **ключевой** колонке — это гонка двух первых обращений одним логином:
// запись успел завести соседний запрос, и надо просто взять его. Отказ, который
// после повторного поиска никуда не делся, — уже не гонка, и его отдают наверх.
//
// По **любой другой** — почта, пришедшая от провайдера, занята другой учётной
// записью: общий почтовый ящик, семья, группа. Запись заводится без почты; она
// необязательна, а ключом не служит. Без этого разреза второй человек с общим
// адресом не завёлся бы никогда — повторный поиск по логину снова ничего не
// нашёл бы, и исход выродился бы либо в цикл, либо в вечный отказ без внятной
// причины.
func retryAfterConflict(app core.App, login string, record *core.Record, saveErr error) (*core.Record, error) {
if isUniqueViolation(saveErr, migrations.ProviderLoginField) {
existing, err := findUserByLogin(app, login)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
}
if !isUniqueViolation(saveErr, core.FieldNameEmail) {
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
}
record.SetEmail("")
if err := app.Save(record); err != nil {
return nil, fmt.Errorf("failed to create user account without email: %w", err)
}
return record, nil
}
// findUserByLogin ищет учётную запись по ключу. Значение уходит хранилищу
// **параметром** запроса, а не подстановкой в текст фильтра: строка приходит
// снаружи, и подставленная в текст она правила бы сам запрос, а не только его
// аргумент.
func findUserByLogin(app core.App, login string) (*core.Record, error) {
records, err := app.FindRecordsByFilter(
migrations.UsersCollection,
migrations.ProviderLoginField+" = {:login}",
"", 1, 0,
map[string]any{"login": login},
)
if err != nil {
return nil, fmt.Errorf("failed to look up user account: %w", err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// uniqueViolationCode — каким кодом библиотека называет отказ уникальности.
//
// Разбор идёт по **коду**, а не по имени текста и не по тексту драйвера: текст
// у драйвера свой на каждую версию, а имя колонки не говорит о причине.
const uniqueViolationCode = "validation_not_unique"
// isUniqueViolation говорит, отказала ли по названной колонке проверка
// **уникальности** — а не какая-нибудь другая.
//
// Разница не педантизм. Под ключом `email` библиотека складывает и отказ
// уникальности, и отказ формы адреса; проверка «есть ли ключ в карте» считала
// бы опечатку прокси занятым семейным ящиком и молча заводила бы запись без
// почты. На ключевой колонке та же неточность когда-нибудь выстрелит громче:
// любой отказ проверки логина читался бы как гонка двух первых обращений.
func isUniqueViolation(err error, field string) bool {
var errs validation.Errors
if !errors.As(err, &errs) {
return false
}
fieldErr, ok := errs[field]
if !ok {
return false
}
var object validation.ErrorObject
if !errors.As(fieldErr, &object) {
return false
}
return object.Code() == uniqueViolationCode
}
// acceptDisplayName приводит имя к годному для колонки значению.
//
// Обрезается по пределу колонки и чистится от управляющих знаков — тем же
// приёмом, каким приём записи чистит имя файла отправителя. Пустое значение
// законно: имени у человека может не быть вовсе.
func acceptDisplayName(value string) string {
name := strings.TrimSpace(stripControls(value))
runes := []rune(name)
if len(runes) > MaxDisplayNameLength {
return string(runes[:MaxDisplayNameLength])
}
return name
}
// acceptEmail отдаёт адрес почты, если он вообще похож на адрес.
//
// Негодный отбрасывается **здесь**, а не отказом сохранения: иначе опечатка в
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью
// «почта занята», и владелец искал бы общий ящик там, где сломан контур.
func acceptEmail(value string) (string, bool) {
email := strings.TrimSpace(value)
if email == "" {
return "", false
}
if err := is.EmailFormat.Validate(email); err != nil {
return "", false
}
return email, true
}
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
// им делать нечего.
func stripControls(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, value)
}
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
// отвергает его.
//
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
//
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
// называет одного человека, а две записи о нём разошлись бы молча.
func AcceptProviderLogin(value string) (string, bool) {
login := strings.TrimSpace(value)
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и
// два предела в разных единицах разошлись бы вдвое на любой кириллице.
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
return "", false
}
for _, r := range login {
if unicode.IsControl(r) {
return "", false
}
}
return login, true
}
@@ -0,0 +1,316 @@
package pocketbase
import (
"strings"
"sync"
"testing"
"unicode/utf8"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// Проверки узнавания: как учётная запись находится и как заводится.
// Заведение идемпотентно: второе обращение попадает в ту же запись и не
// переписывает её.
//
// Не переписывает — половина требования, и она отдельная: перепись на каждом
// запросе означала бы запись в базу на каждый запрос, а правка имени у
// провайдера меняла бы карточку человека молча, посреди его работы.
func TestEnsureUserIsIdempotent(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Алиса", Email: "alice@example.test"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Другое имя", Email: "other@example.test"})
require.NoError(t, err)
assert.Equal(t, first.Id, second.Id, "второе обращение завело вторую запись")
assert.Equal(t, "Алиса", second.GetString("name"), "имя переписано вторым обращением")
assert.Equal(t, "alice@example.test", second.Email(), "почта переписана вторым обращением")
assert.Equal(t, 1, countUsers(t, app))
}
// Одновременные первые обращения одним логином дают одну учётную запись.
//
// Проверка стоит потому, что норма без неё держалась бы на одном уникальном
// индексе: забытый в шаге схемы, он дал бы зелёную приёмку и две учётные записи
// на одного человека — а архив разъехался бы между ними молча и склеить его было
// бы нечем.
func TestEnsureUserSurvivesConcurrentFirstRequests(t *testing.T) {
app := newTestStorage(t)
const racers = 8
var wg sync.WaitGroup
ids := make([]string, racers)
errs := make([]error, racers)
start := make(chan struct{})
for i := range racers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
record, _, err := EnsureUser(app, Identity{Login: "racer", Name: "Гонщик"})
errs[i] = err
if record != nil {
ids[i] = record.Id
}
}()
}
close(start)
wg.Wait()
for i, err := range errs {
require.NoError(t, err, "обращение %d отказало", i)
}
for i, id := range ids {
assert.Equal(t, ids[0], id, "обращение %d попало в другую учётную запись", i)
}
assert.Equal(t, 1, countUsers(t, app), "гонка завела больше одной учётной записи")
}
// Занятая почта не мешает завести запись: она достаётся первому, а второй
// заводится без неё.
//
// Общий почтовый ящик — обычное дело в семье, а Authelia вправе отдать один
// адрес группе. Без разреза двух отказов уникальности второй человек не завёлся
// бы никогда: повторный поиск по логину снова ничего не находит.
func TestEnsureUserWithTakenEmail(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "one", Email: "family@example.test"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: "two", Email: "family@example.test"})
require.NoError(t, err)
assert.NotEqual(t, first.Id, second.Id)
assert.Equal(t, "family@example.test", first.Email(), "почта досталась первому")
assert.Empty(t, second.Email(), "второму почта не досталась, но запись завелась")
assert.Equal(t, 2, countUsers(t, app))
}
// Вырожденный логин никого не узнаёт и ничего не заводит.
func TestEnsureUserRejectsDegenerateLogin(t *testing.T) {
app := newTestStorage(t)
values := map[string]string{
"пустой": "",
"одни пробелы": " \t ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", MaxProviderLoginLength+1),
}
for name, value := range values {
t.Run(name, func(t *testing.T) {
record, _, err := EnsureUser(app, Identity{Login: value})
assert.Nil(t, record)
require.ErrorIs(t, err, ErrLoginNotAcceptable)
assert.Equal(t, 0, countUsers(t, app))
})
}
}
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
// называет одного человека, и две записи о нём разошлись бы молча.
func TestEnsureUserTrimsSurroundingSpaces(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: " alice "})
require.NoError(t, err)
assert.Equal(t, first.Id, second.Id)
assert.Equal(t, 1, countUsers(t, app))
}
// Сравнение точное: приведение регистра завело бы правило, которого у
// провайдера нет, — и склеило бы двух разных людей.
func TestEnsureUserComparesExactly(t *testing.T) {
app := newTestStorage(t)
lower, _, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
upper, _, err := EnsureUser(app, Identity{Login: "Alice"})
require.NoError(t, err)
assert.NotEqual(t, lower.Id, upper.Id)
assert.Equal(t, 2, countUsers(t, app))
}
// Значение, похожее на условие отбора, ищется как значение, а не как часть
// запроса: оно уходит хранилищу параметром.
func TestEnsureUserDoesNotLetLoginChangeTheQuery(t *testing.T) {
app := newTestStorage(t)
victim, _, err := EnsureUser(app, Identity{Login: "victim"})
require.NoError(t, err)
attacker, _, err := EnsureUser(app, Identity{Login: `x" || provider_login = "victim`})
require.NoError(t, err)
assert.NotEqual(t, victim.Id, attacker.Id,
"значение изменило сам запрос и вернуло чужую учётную запись")
assert.Equal(t, 2, countUsers(t, app))
}
func countUsers(t *testing.T, app core.App) int {
t.Helper()
records, err := app.FindAllRecords(migrations.UsersCollection)
require.NoError(t, err)
return len(records)
}
// Признак заведения отличает первое обращение от всех следующих.
//
// По нему слой узнавания пишет строку журнала, и без него владелец не отличит
// «никто не заходил» от «завелось двадцать»: убрать заведённую запись потом
// нечем.
func TestEnsureUserReportsWhetherItCreated(t *testing.T) {
app := newTestStorage(t)
_, created, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
assert.True(t, created, "первое обращение не назвалось заведением")
_, created, err = EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
assert.False(t, created, "второе обращение назвалось заведением")
}
// Негодное имя не отменяет заведения: оно обрезается по пределу колонки.
//
// Прежде имя уходило в колонку как есть, и человек с длинным именем у
// провайдера получал отказ сервиса на **каждом** запросе — учётная запись не
// заводилась никогда, а починить у себя он ничего не мог.
func TestEnsureUserAcceptsDegenerateName(t *testing.T) {
app := newTestStorage(t)
long := strings.Repeat("я", MaxDisplayNameLength+50)
record, created, err := EnsureUser(app, Identity{Login: "bob", Name: long})
require.NoError(t, err, "негодное имя отменило заведение записи")
require.True(t, created)
name := record.GetString("name")
assert.Equal(t, MaxDisplayNameLength, utf8.RuneCountInString(name), "имя не обрезано по пределу")
assert.NotEmpty(t, name)
}
// Управляющие знаки из имени убираются: значение приезжает заголовком.
func TestEnsureUserStripsControlsFromName(t *testing.T) {
app := newTestStorage(t)
record, _, err := EnsureUser(app, Identity{Login: "carol", Name: "Ка\x00ро\nл"})
require.NoError(t, err)
assert.Equal(t, "Карол", record.GetString("name"))
}
// Негодная почта отбрасывается **явно**, а не через ветвь «почта занята».
//
// Иначе опечатка в контуре неотличима от общего семейного ящика, и владелец
// ищет второго человека там, где сломан прокси.
func TestEnsureUserDropsMalformedEmail(t *testing.T) {
app := newTestStorage(t)
record, created, err := EnsureUser(app, Identity{Login: "dave", Email: "не-адрес"})
require.NoError(t, err, "негодная почта отменила заведение записи")
require.True(t, created)
assert.Empty(t, record.Email())
}
// Предел логина считается в знаках, а не в байтах: колонка считает знаки.
//
// Прежде кириллический логин длиннее половины предела отвергался навсегда,
// хотя колонка приняла бы его.
func TestEnsureUserCountsLoginInRunes(t *testing.T) {
app := newTestStorage(t)
login := strings.Repeat("я", MaxProviderLoginLength)
record, _, err := EnsureUser(app, Identity{Login: login})
require.NoError(t, err, "логин ровно на пределе отвергнут: предел считается в байтах")
assert.Equal(t, login, record.GetString(migrations.ProviderLoginField))
_, _, err = EnsureUser(app, Identity{Login: strings.Repeat("я", MaxProviderLoginLength+1)})
assert.ErrorIs(t, err, ErrLoginNotAcceptable, "логин сверх предела принят")
}
// Ключ учётной записи не меняется после заведения — ни правкой в панели, ни
// прямым сохранением.
//
// Правила доступа коллекции закрывают только путь снаружи; панель работает
// суперпользователем. Переписанный ключ отдал бы весь архив следующему, кто
// придёт с этим именем, и вернуть его было бы нечем.
func TestProviderLoginIsImmutable(t *testing.T) {
app := newTestStorage(t)
BindPanelRules(app)
record, _, err := EnsureUser(app, Identity{Login: "victim-owner"})
require.NoError(t, err)
record.Set(migrations.ProviderLoginField, "someone-else")
err = app.Save(record)
require.Error(t, err, "ключ учётной записи переписан прямым сохранением")
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, "victim-owner", stored.GetString(migrations.ProviderLoginField))
}
// Правка прочих полей учётной записи при этом проходит: хук сторожит один ключ,
// а не запирает коллекцию целиком.
func TestUserRecordStaysEditableExceptTheKey(t *testing.T) {
app := newTestStorage(t)
BindPanelRules(app)
record, _, err := EnsureUser(app, Identity{Login: "editable", Name: "Прежнее"})
require.NoError(t, err)
record.Set("name", "Новое")
require.NoError(t, app.Save(record), "правка имени в панели отвергнута")
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, "Новое", stored.GetString("name"))
}
// Две учётные записи без ключа уживаются: индекс частичный, как и соседний
// индекс почты.
//
// Сплошной индекс ронял бы накатку шага на всякой базе, где записей больше
// одной, — то есть у разработчика, ходившего прежним рецептом входа.
func TestEmptyProviderLoginDoesNotCollide(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
for _, name := range []string{"Первый", "Второй"} {
record := core.NewRecord(users)
record.Set("name", name)
record.SetRandomPassword()
require.NoError(t, app.Save(record), "вторая запись без ключа отвергнута индексом")
}
assert.Equal(t, 2, countUsers(t, app))
}
@@ -0,0 +1,125 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// ProviderLoginField — колонка, в которой лежит ключ учётной записи: логин
// человека у провайдера, тот самый, которым его называет обратный прокси.
//
// Имя говорит о происхождении значения, а не о заголовке, которым оно приехало:
// заголовок — способ доставки и может смениться, а логин у провайдера — то, чем
// значение является. Колонка уезжает шагом схемы и потому не переименовывается.
const ProviderLoginField = "provider_login"
// providerLoginIndex — имя уникального индекса по ключу учётной записи.
const providerLoginIndex = "idx_users_provider_login"
// up202608220001 переводит узнавание пришедшего с протокола OIDC на логин,
// который называет доверенный источник.
//
// Три части, и каждая закрывает своё.
//
// Первая — ключ учётной записи. Прежде идентичность человека лежала в системной
// таблице внешних учётных записей библиотеки: её вела механика обмена кода, и
// правил её только владелец панели. Механика уходит, и ключу нужен свой дом —
// колонка с уникальным индексом. Почта ключом не годится: провайдер не обязан
// её приносить, человек её меняет, а первое обращение с чужим адресом досталось
// бы чужой записи.
//
// Вторая — необязательная почта. Умолчание библиотеки требует непустого адреса
// у всякой учётной записи; заголовка с почтой может не быть вовсе, а
// уникальность почты держится **частичным** индексом (`WHERE email != ”`),
// поэтому записи без почты уживаются друг с другом. Пароль остаётся
// обязательным при любом значении признака — ему ставится случайный, употребить
// его нельзя: вход по паролю у коллекции выключен прежним шагом.
//
// Третья — поверхность коллекции пользователей. Умолчание библиотеки открывает
// владельцу записи чтение, правку и удаление собственной строки, и до сих пор
// это ничему не мешало ровно потому, что до поверхности хранилища браузер с
// кукой не дотягивался: слой предъявления жил под корнем приложения. С
// узнаванием по заголовку такая защита перестаёт быть защитой, а ключ учётной
// записи лежит теперь обычной колонкой — то есть правка своей записи и есть
// захват чужого имени: поставил себе ключом чужой логин, и первое обращение
// настоящего его владельца попало бы в твою запись вместе со всем архивом.
// Правила снимаются в пустое, что у хранилища означает «только владелец
// панели»; наш код читает и заводит запись мимо правил, панель работает
// суперпользователем, своих экранов профиля сервис не заводит.
func up202608220001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.Fields.Add(&core.TextField{
Name: ProviderLoginField,
// Предел тот же, что у имени в умолчании библиотеки: логин длиннее
// имени не бывает, а колонка без предела принимала бы килобайты,
// пришедшие заголовком.
Max: 255,
})
// Индекс **частичный** — ровно как соседний индекс почты у той же коллекции.
// Сплошной запретил бы вторую запись с пустым ключом, а такая заводится
// рукой в панели и остаётся у всякой базы, пережившей прежний вход: подъём
// на ней ронял бы накатку шага отказом уникальности, и сервис не стартовал
// бы вовсе. Пустым ключом при этом не узнаётся никто — это держит приём
// значения, а не индекс.
users.AddIndex(providerLoginIndex, true, ProviderLoginField, ProviderLoginField+" != ''")
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
if !ok {
return errors.New("users collection has no email field")
}
email.Required = false
// Механика OIDC снимается целиком: настройки провайдера больше не приводятся
// к конфигу при подъёме, и обменивать код не на что.
users.OAuth2.Enabled = false
users.OAuth2.Providers = nil
// Наглухо все пять: заведение записи идёт нашим кодом, мимо правил.
users.ListRule = nil
users.ViewRule = nil
users.CreateRule = nil
users.UpdateRule = nil
users.DeleteRule = nil
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to switch users collection to provider login: %w", err)
}
return nil
}
// down202608220001 убирает ключ учётной записи и возвращает обязательность
// почты.
//
// Правила доступа сюда не возвращаются намеренно — ни открытое создание записи,
// которое закрывал прежний шаг, ни открытая правка, которую закрывает этот.
// Откат, восстанавливающий их, оставил бы сервис хуже, чем он был: правка своей
// записи открыта только тому, кто узнан, а узнают теперь по колонке, которую эта
// правка и переписывает.
func down202608220001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.RemoveIndex(providerLoginIndex)
users.Fields.RemoveByName(ProviderLoginField)
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
if !ok {
return errors.New("users collection has no email field")
}
email.Required = true
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to restore users collection: %w", err)
}
return nil
}
@@ -51,6 +51,7 @@ func init() {
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go")
pbmigrations.Register(up202608220001, down202608220001, "202608220001_trusted_header_login.go")
}
func ptr[T any](v T) *T { return &v }
+45
View File
@@ -1,6 +1,8 @@
package pocketbase
import (
"errors"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
@@ -31,6 +33,8 @@ import (
// бы тем же сохранением, а число отказов остановленной записи — которое
// остановка хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) {
bindProviderLoginIsImmutable(app)
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil {
@@ -84,3 +88,44 @@ func BindPanelRules(app core.App) {
return nil
})
}
// bindProviderLoginIsImmutable запрещает менять ключ учётной записи после
// заведения.
//
// Ключ — логин человека у провайдера, и по нему сервис узнаёт пришедшего.
// Переписанный, он отдаёт весь архив прежнего владельца следующему, кто придёт
// с этим именем: владелец записи назначается один раз и не меняется, так что
// вернуть архив будет нечем. Молча — журнала событий у коллекции пользователей
// нет.
//
// Правила доступа коллекции закрывают этот путь **снаружи**, но не изнутри:
// панель работает суперпользователем и правила обходит по построению. Отсюда
// хук, и он вешается на **модельное** событие, а не на правку запросом — иначе
// панель осталась бы незакрытой, а закрывать её и есть весь смысл.
//
// Заведение проходит: событие правки на нём не срабатывает вовсе.
//
// Прежнее значение читается **из базы**, а не из снимка правящейся записи.
// Снимок у записи, только что заведённой в этом же процессе, пуст — он не
// обновляется сохранением, — и сторож, опирающийся на него, пропускал бы правку
// в зависимости от того, откуда вызывающий взял запись. Панель её загружает, и
// на ней сторож сработал бы; молчаливая же зависимость от способа получения —
// ровно тот класс, из-за которого правило и заводится.
func bindProviderLoginIsImmutable(app core.App) {
app.OnRecordUpdate(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
stored, err := e.App.FindRecordById(migrations.UsersCollection, e.Record.Id)
if err != nil {
// Записи в базе нет — правки тоже нет: сохранение отвергнется само.
return e.Next()
}
was := stored.GetString(migrations.ProviderLoginField)
now := e.Record.GetString(migrations.ProviderLoginField)
if was != "" && was != now {
return errors.New("provider login is assigned once and never changes")
}
return e.Next()
})
}
@@ -1,72 +0,0 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
const ProviderName = "oidc"
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
// применяется, потому что оно никем не выбрано.
//
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
// хранилище продолжило выдавать прежний.
const SessionDuration = 7 * 24 * 60 * 60
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
// коллекции.
type ProviderSettings struct {
AuthURL string
TokenURL string
UserInfoURL string
ClientID string
ClientSecret string
}
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
// к значениям конфига.
//
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
// только руками в панели.
//
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
// имя коллекции, а не значения.
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
// сроком жизни куки.
users.AuthToken.Duration = SessionDuration
users.OAuth2.Enabled = true
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: ProviderName,
ClientId: settings.ClientID,
ClientSecret: settings.ClientSecret,
AuthURL: settings.AuthURL,
TokenURL: settings.TokenURL,
UserInfoURL: settings.UserInfoURL,
DisplayName: "Authelia",
}}
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
}
return nil
}
@@ -74,6 +74,35 @@ func TestRecordContentIsClosedEverywhere(t *testing.T) {
// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а
// пустая копия висела бы в панели вторым домом для понятия, которого больше нет.
// Поверхность коллекции пользователей закрыта наглухо — все пять правил.
//
// Проверка стоит отдельно от соседней намеренно: та сторожит коллекции, которые
// заводит наш шаг схемы, а эту заводит системный шаг библиотеки, и её умолчания
// открывают владельцу записи чтение, правку и удаление собственной строки. Пока
// узнавание жило под корнем приложения, до этой поверхности браузер не
// дотягивался вовсе; с узнаванием по заголовку она достижима, а ключ учётной
// записи лежит здесь обычной колонкой — правка своей записи и есть захват чужого
// имени.
func TestUsersCollectionSurfaceIsClosed(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
assert.Nil(t, users.ListRule, "перечисление учётных записей закрыто")
assert.Nil(t, users.ViewRule, "чтение учётной записи закрыто")
assert.Nil(t, users.CreateRule, "заведение учётной записи снаружи закрыто")
assert.Nil(t, users.UpdateRule, "правка учётной записи снаружи закрыта")
assert.Nil(t, users.DeleteRule, "удаление учётной записи снаружи закрыто")
// Собственные способы войти выключены там же: без этого узнавание по
// заголовку обходится двумя запросами — завести себе запись и войти паролем.
assert.False(t, users.PasswordAuth.Enabled, "вход по паролю выключен")
assert.False(t, users.OTP.Enabled, "вход по одноразовому коду выключен")
assert.False(t, users.OAuth2.Enabled, "обмен кода у внешнего провайдера выключен")
assert.Empty(t, users.OAuth2.Providers, "настроенных провайдеров не осталось")
}
func TestFormerJobsCollectionIsGone(t *testing.T) {
app := newTestStorage(t)
+58 -56
View File
@@ -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{},
}
}
+35 -67
View File
@@ -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()
+8 -7
View File
@@ -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"),
-382
View File
@@ -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,
})
}
+468 -366
View File
@@ -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(), "приложение")
}
+13 -5
View File
@@ -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)
+246
View File
@@ -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
}
+1 -1
View File
@@ -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)
-358
View File
@@ -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")
}
+31 -30
View File
@@ -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"`
+34
View File
@@ -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
}
-72
View File
@@ -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()
},
}
}
+2 -1
View File
@@ -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)
+52 -27
View File
@@ -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,
}
}
+8 -7
View File
@@ -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)},
}
+9 -13
View File
@@ -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)
+3
View File
@@ -716,6 +716,9 @@ func newOwner(t *testing.T, app core.App) string {
require.NoError(t, err)
record := core.NewRecord(users)
// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
// пустым ключом схема не примет.
record.Set(migrations.ProviderLoginField, uuid.NewString())
record.Set("email", uuid.NewString()+"@example.test")
record.Set("verified", true)
record.Set("password", uuid.NewString())
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-22
@@ -0,0 +1,361 @@
## Context
Сегодня сервис проводит вход сам: адрес `/auth/login` уводит человека к
Authelia, положив состояние и проверочный код PKCE в куку `transcriber_login`;
`/auth/callback` сверяет состояние и меняет принесённый код на сессию
внутрипроцессным запросом к собственному адресу хранилища; сессия уезжает кукой
`transcriber_session` и живёт семь суток. Секрет клиента ради этого обмена лежит
в конфиге и приводится к настройкам коллекции пользователей при каждом подъёме.
Обратный прокси контура (`files/caddyproxy/Caddyfile.template` в
`pet-project-server`) уже спрашивает Authelia через `forward_auth` и копирует
ответ в заголовки `Remote-User`, `Remote-Groups`, `Remote-Email`, `Remote-Name`
трём соседним сервисам. Правила для этого сервиса там нет: он не выложен.
Контейнер портов наружу не публикует.
Проект на стройке: данных на сервере нет, совместимость не требуется. Применённый
шаг схемы по-прежнему не переписывается.
**Что измерено пробником** на временной базе (прогон 2026-08-22, программа
удалена):
- коллекция `users` требует непустых `email` и `password`; поле `email`
переводится в необязательное шагом схемы, `password` — нет ни при каком
значении признака;
- уникальность почты держится **частичным** индексом (`WHERE email != ''`),
поэтому запись без почты законна и второй такой же не мешает;
- своя колонка с уникальным индексом отвергает вторую запись с тем же значением
и находится поиском по значению;
- запись, заведённая так, выдаёт файловый токен — то есть годится как учётная
запись предъявителя во всём, что делает сервис.
## Goals / Non-Goals
**Goals:**
- Узнавать пришедшего заголовком доверенного источника и никак иначе.
- Убрать собственный протокол входа целиком, вместе с куками, состоянием, PKCE,
обменом кода и выходом.
- Убрать секрет клиента из конфига и из базы.
- Оставить рабочим путь к файлу записи: предъявиться, взять у хранилища короткий
файловый токен, пройти по ссылке.
- Оставить рабочим вход владельца в панель его собственным паролем.
- Дать способ представиться на машине без прокси.
**Non-Goals:**
- Второй уровень доступа по `Remote-Groups`: его потребителя — страницы расхода
— ещё нет.
- Личные токены для программ: их заводит `api-tokens`.
- Правило обратного прокси и правило Authelia для домена сервиса: они живут в
`pet-project-server`, и эта работа их не пишет и не проверяет.
- Приведение пути к канонической форме (обход панели подменой знака): барьер
переезжает на домен, и правило на литерал пути перестаёт быть единственным, но
сама канонизация — не предмет этой работы.
## Decisions
### Р1. Доверие определяется адресом пира соединения
Заголовку верят, когда запрос пришёл с адреса из объявленного перечня. Адрес
берётся у самого соединения (`RemoteAddr`), а не из пересылаемых заголовков.
Отвергнуто:
- **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
запрос. Барьер, который подделывается той же строкой, что и обходится, не
барьер вовсе.
- **Готовое разрешение адреса у библиотеки** (`RealIP`). Оно читает
пересылаемый заголовок, когда настроен список доверенных прокси, а сам список
живёт в настройках хранилища — то есть в базе, а не в конфиге. Условие доверия
оказалось бы в месте, которое правят руками в панели.
- **«Контейнер портов не публикует, значит доверяем всякому».** Условие верное,
но непроверяемое отсюда: правка раскладки контейнера открыла бы сервис молча,
и оракула у второго критерия приёмки не было бы вовсе.
### Р2. Слой действует на объявленной области, а не на всём роутере
Слой вешается корневым — иначе к нужным адресам его не привязать, — но узнаёт
пришедшего **только на объявленной области**: корень приложения плюс адрес,
которым хранилище выдаёт короткий файловый токен. Область берётся из перечня
адресного пространства, а не пишется вторым списком; адрес файлового токена
добавляется к нему поимённо. Устроено это ровно так же, как сегодня устроен
запрет продления сессии: корневая привязка и проверка пути внутри.
Учётная запись ставится **только когда её ещё нет** — то есть после того, как
библиотека прочитала предъявленный токен и никого не нашла. Предъявленным
считается токен, который библиотека **прочитала успешно**: протухший и негодный
предъявленными не считаются, и такой запрос узнаётся заголовком.
**Первая редакция вешала узнавание на весь роутер, и это была дыра.** Прежняя
спека держала обратное требование — «область действия слоя MUST быть ограничена
адресами приложения… часть поверхности хранилища защищена сегодня ровно тем, что
браузер заголовка сам не шлёт, и расширение слоя на всё сняло бы эту защиту
молча», — а первая редакция снимала это требование вместе с кукой и замены ему не
давала. Сложение с Р5 (ключ учётной записи переезжает в обычную колонку
пользователей) давало путь захвата: узнанный человек шлёт
`PATCH /api/collections/users/records/{свой id}` и ставит себе ключом чужое имя,
а первое обращение настоящего владельца этого имени попадает в **его** учётную
запись — вместе со всем архивом. Правило правки у коллекции пользователей
библиотечное и разрешает править свою запись; **проверено пробником**: у
коллекции `users` `ListRule`, `ViewRule`, `UpdateRule` и `DeleteRule` равны
`id = @request.auth.id`, а применённый шаг `202608120001_oidc_login` трогает
только правило создания.
Второе следствие сужения: узнавание больше не срабатывает на пробе здоровья, на
метриках и на каждом ресурсе сборщика. Иначе всякий запрос за картинкой стоил бы
обращения к базе, а первый такой запрос с новым именем — записи в неё.
Отвергнуто: **вешать под корнем приложения и завести отдельный путь к файлу**.
Второй способ добраться до файла — второй дом одного правила, и разошлись бы они
на первой же правке разграничения.
### Р2а. Поверхность коллекции пользователей закрывается схемой
Новый шаг схемы снимает у коллекции пользователей все правила доступа —
перечисление, чтение, создание, правку и удаление, — оставляя их пустыми, то есть
«только владелец панели». Наш код читает и заводит запись мимо правил, панель
работает суперпользователем, своих экранов профиля сервис не заводит.
Это второй барьер поверх Р2, и он нужен именно вторым. Прежняя спека сама
называла защиту, державшуюся на том, что браузер не шлёт заголовка,
**случайной** — и была права; область слоя это та же случайность, только с
другой стороны. Сверку берёт на себя проверка схемы: коллекция пользователей
дописывается к шести, которые она уже сторожит, — сегодня она единственная из
семи проверяется только умолчаниями библиотеки.
Отсюда же норма, которой в первой редакции не было вовсе: **ключ учётной записи
не правится ничем, кроме заведения самим сервисом.** Правило доступа закрывает
путь снаружи, норма закрывает и правку рукой в панели: переписанный ключ отдаёт
архив следующему, кто придёт с этим именем, и вернуть его будет нечем.
### Р3. Сессия не выдаётся никакая
Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
заголовку, который прокси поставил, сходив к Authelia.
Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
значение, живущее семь суток, отозванный у провайдера человек работал до
истечения этого значения, и другого канала отзыва не было.
Отвергнуто: **выдавать сессию хранилища по первому запросу с заголовком** — куку
ставить, дальше верить ей. Дешевле в работе (слой срабатывал бы раз в неделю, а
не на каждом запросе), но возвращает ровно то, что задача убирает: значение,
переживающее отзыв. Семь суток вернулись бы вместе с ним.
Цена принятого решения: поиск учётной записи по значению заголовка идёт на
**каждом** запросе к сервису. Уникальный индекс делает это одним обращением к
базе; сервисом пользуются единицы человек, и замера здесь не требуется.
### Р4. Имена заголовков нормативны, настраивается перечень доверенных адресов
Имена `Remote-User`, `Remote-Name`, `Remote-Email` записываются в спеку и
живут константами — как жило имя куки сессии, и по той же причине: смена имени
молча перестаёт узнавать всех, а тест, который сам ставит и сам читает своё имя,
этого не замечает. Контур уже пишет эти имена трём соседним сервисам.
Настройкой приходит одно — перечень адресов, чьему заголовку верят. Он
непустой обязателен: пустой перечень значит «не верить никому», то есть сервис,
поднявшийся никого не узнающим, и молчать об этом старт не вправе.
Отвергнуто: **имена заголовков ключами конфига**. Второе место, где их можно
написать неверно, а выгоды нет: контур один и пишет одно.
Имена ключей конфига — необратимое. **Решено человеком на чекпоинте
2026-08-22:** секция `[auth]` остаётся под своим именем, в ней один ключ —
`trusted_proxies`. Довод: секция сохраняет смысл «кому мы верим на входе», а
проверка настроек на старте остаётся там же, где была.
### Р5. Ключ учётной записи — своя колонка
Значение `Remote-User` ложится в свою колонку коллекции пользователей,
уникальную. По ней запись ищется и по ней же заводится, если её ещё нет.
Отвергнуто:
- **Адрес почты ключом.** Authelia не обязана его отдавать, человек его меняет,
а первый вход с чужим адресом достался бы чужой записи — ровно тот захват,
ради которого прежний шаг схемы закрывал создание записи.
- **Таблица внешних учётных записей библиотеки.** Она принадлежит механике
OAuth2, которую эта работа убирает целиком.
- **Не брать `Remote-Email` вовсе.** Довод за: поля с личными сведениями,
у которого нет ни одного потребителя, в периметре быть не должно — ключом
почта не служит, в журнал и в ответ ей ходу нет, а ради неё шаг схемы ещё и
делает колонку необязательной. Отвергнуто потому, что потребитель назван и
ждёт своей очереди: задача `email-notification` шлёт готовый текст на адрес из
учётной записи, и строка про это стоит в модели угроз, раздел «Куда уходит
содержимое записи». Взять адрес заголовком в тот день будет нечем — заголовок
приходит с запросом человека, а рассылка идёт из конвейера.
### Р5а. Цена ключа названа в обе стороны
Переименование у провайдера заводит новую учётную запись — это первая половина, и
она была названа сразу. Вторая: **логин переиспользуем**. Человек, которому
выдали логин ушедшего, при первом же обращении попадает в **существующую** запись
и получает весь её архив — голосовые записи семьи, расшифровки, тексты, то есть
самое чувствительное, что у сервиса есть.
Записывается это в спеку и повторяется в модели угроз, а не чинится: неизменяемого
признака заголовок не приносит, и не допускать переиспользования логинов — работа
провайдера, а не сервиса. Дефект был бы в умолчании — в том, что риск не назван, и
следующий читатель счёл бы вопрос закрытым абзацем про переименование.
Третья сторона той же цены: у осиротевшей записи прежнего человека остаётся архив,
который нечем ни слить с новой, ни убрать — владелец записи назначается один раз и
не меняется, а учётная запись с записями не удаляется по норме `storage`.
Имя колонки — `provider_login`, и оно говорит о происхождении значения: это логин
человека **у провайдера**, а не наш идентификатор. Имя уезжает шагом схемы и
потому не переписывается.
Заведение записи: имя — из `Remote-Name`, почта — из `Remote-Email`, если тот
пришёл. Почта перестаёт быть обязательной (шаг схемы), пароль записи
обязателен всегда — ей ставится случайный, употребить его нельзя, потому что
вход по паролю у коллекции выключен.
Найденную запись повторное обращение **не переписывает**: имя и почта берутся
только при заведении. Иначе каждый запрос был бы записью в базу, а правка имени
в Authelia переписывала бы карточку человека молча, посреди его работы.
**Значение принимается, а не берётся как есть.** Пустое и состоящее из пробельных
знаков не узнаёт никого и записи не заводит: прокси штатно шлёт пустой заголовок
там, где никого не назвал, и по букве «первое обращение с новым значением» все
неназванные собрались бы в одну общую учётную запись с общим архивом. По той же
причине отвергается запрос, несущий **более одного** значения `Remote-User`:
прокси, настроенный добавлять заголовок вместо замены, оставляет присланный
анонимом рядом со своим, и умолчание «берём первый» отдаёт вход анониму. Сверх
того — предел длины и отказ на управляющие знаки, а в хранилище значение уходит
параметром, а не подстановкой в текст фильтра. Класс у проекта уже был: хвост
имени отправителя, уехавший меткой метрики, чинился приведением на входе, а не
запретом на выходе.
Сравнение при поиске — точное, знак в знак. Приведение регистра завело бы правило,
которого нет у провайдера: считает ли Authelia `admin` и `Admin` одним человеком,
сервису неизвестно, а угаданное правило склеило бы двух разных людей молча.
**Два отказа уникальности различаются.** По ключевой колонке — гонка двух первых
обращений одним именем: код повторяет поиск и продолжает. По любой другой —
почта, пришедшая от провайдера, уже занята другой учётной записью (общий
почтовый ящик, семья, группа) — запись заводится **без почты**, потому что почта
необязательна, и остаётся строка журнала с идентификатором записи. Без этого
разреза второй человек с той же почтой не завёлся бы никогда: повторный поиск по
имени снова ничего не нашёл бы, и исход выродился бы либо в цикл, либо в вечный
отказ без внятной причины.
**Отказ хранилища при узнавании — отказ сервиса, а не «вас не узнали».** Молчаливый
проход неузнанным показал бы человеку отказ входа там, где легла база.
### Р6. Заголовок с недоверенного адреса просто не действует
Слой при этом отказа не выдаёт. Отказ приходит там, где приходил и раньше, —
требованием учётной записи под корнем приложения, кодом `401`.
Отвергнуто: **отвечать отказом прямо в слое**. Проба здоровья, метрики и
разметка приложения открыты анонимно по спеке; отказ в слое закрыл бы их
всякому, кто пришлёт заголовок, — то есть чужая строка в запросе гасила бы
наблюдение за сервисом.
### Р7. Значение заголовка в журнал не идёт
Пишется факт и исход — «заголовок пришёл с недоверенного адреса», «учётная
запись заведена», — с идентификатором записи, но без значения. Довод тот же,
каким причина отказа провайдера приводилась к перечню известных: значением
целиком распоряжается тот, кто шлёт запрос, а с недоверенного адреса это ещё и
аноним. Плюс само имя принадлежит человеку — наравне с адресом почты, которому
спека уже запрещает попадать в журнал.
### Р7а. У правила «найти или завести человека» есть дом, и он не в транспорте
Правило — поиск по ключу, заведение при отсутствии, приём значения, разрез двух
отказов уникальности, «найденную не переписывать» — живёт **методом пакета
хранилища**, а слой транспорта только читает заголовок, судит адрес пира и зовёт
метод. Дом объявляется строкой в перечне единых точек проекта.
Причина в следующей задаче, а не в чистоте. `api-tokens` заводит **второй** способ
представиться и в своей записи прямо говорит: «два способа представиться и один
владелец». Правило, уложенное куском в слой транспорта, придётся тогда либо
продублировать вторым куском — и он разойдётся с первым на первой же правке, —
либо вытаскивать задним числом. Стоимость следующего изменения здесь считается:
при вынесенном правиле новый способ представиться стоит одного нового слоя.
Отвергнуто: **контракт в `internal/contract` с реализацией в адаптере**. Дороже на
интерфейс, у которого сегодня одна реализация и второго потребителя вне HTTP не
предвидится; правила направления зависимостей транспорту знать адаптер хранилища
разрешают.
### Р8. Способ представиться без прокси — развилка
Подставной провайдер уходит вместе с протоколом, и его место пусто. На это место
опирается `dev-run-task`, которая обещает адрес, по которому открывают
приложение. Варианты:
| Способ | Что даёт | Цена |
| --- | --- | --- |
| **Один main-пакет оснастки с подкомандами**`cmd/devtools proxy` ставит заголовок и переправляет запрос сервису, `cmd/devtools admin` заводит владельца панели | Приложение открывается браузером обычным образом; сервис остаётся без единой ветки для разработчика; строка исключения в сборке образа остаётся одна навсегда | Соседняя задача `dev-run-task` планировала свой пакет `cmd/devadmin` — её придётся переформулировать под подкоманду |
| **Отдельный main-пакет на каждый инструмент** — как было с подставным провайдером | Ничего не надо согласовывать с соседней задачей | Каждый инструмент стоит четырёх мест: строка сборки образа, «Деплой» в устройстве, «Команды» в памятке, `README`. Забытая строка сборки тихо кладёт инструмент разработчика в боевой образ, а инструментов сразу становится два |
| **Контейнер с прокси вместо своего кода** — контур и так стоит на Caddy, docker и так требование к машине разработчика | Каталог точек входа не растёт ни на один пакет | Ещё один контейнер в локальном прогоне и конфиг прокси, который никто не проверяет; отладка «почему не узнан» уходит в чужой контейнер |
| **Ключ конфига, подставляющий имя** | Один процесс вместо двух | В боевом коде появляется ветка, пускающая без всякой проверки; от беды её отделяет только правильность конфига на сервере. Сверх того — **прямо противоречит требованию дельты**: сервис узнаёт пришедшего по заголовку с доверенного адреса и никак иначе, значит цена включает переоткрытие спеки |
**Решено человеком на чекпоинте 2026-08-22:** первый вариант — один пакет
оснастки `cmd/devtools` с подкомандами. Эта работа заводит пакет и подкоманду
`proxy`; подкоманду `admin` заводит задача `dev-run-task`, и её запись надо
переформулировать — она планировала свой отдельный пакет. Строка исключения в
сборке образа остаётся одна навсегда.
## Risks / Trade-offs
- **Весь барьер держится на том, что прокси заголовок перезаписывает, а не
пропускает пришедший.** Запрос от анонима приходит к сервису с адреса прокси,
то есть с доверенного; если прокси не заменит `Remote-User` своим значением, а
оставит присланный, сервис пустит кого угодно под любым именем. Проверить это
репозиторием нечем — правило живёт в `pet-project-server`. → Требование к
контуру называется поимённо в модели угроз, и выкладку запускает человек.
- **Правило Authelia на домен сервиса не заведено.** Тогда прокси заголовка не
ставит вовсе, сервис никого не узнаёт, и все адреса приложения отвечают `401`.
→ Отказ громкий и одинаковый для всех, а перечень доверенных адресов сервис
называет строкой журнала при подъёме.
- **Переименование пользователя в Authelia заводит новую учётную запись**, и
прежние записи остаются у прежней. → Принятая цена, названная в постановке;
записывается в спеку, а не обходится.
- **Два первых обращения одним именем одновременно.** Оба не найдут записи и оба
примутся её заводить. → Уникальный индекс отвергает второго; код на отказ
уникальности повторяет поиск, а не падает.
- **Учётная запись заводится анонимом, если прокси настроен неверно.** Прежде
запись заводилась только успешным входом у провайдера. → Тот же риск, что и
первый, и то же смягчение: заводит её обращение с доверенного адреса, а
доверенный адрес — это прокси.
- **Перечень доверенных адресов, накрывающий весь интернет**, читается как
подсеть и стартует молча. Старт роняется на пустом и на нечитаемом перечне, но
«доверять всякому» остаётся достижимым настройкой — тем самым, что дизайн
отверг решением. → Смягчения барьером нет намеренно: порог «эта подсеть
слишком широка» обходится двумя половинками той же подсети, то есть был бы
барьером на вид. Наблюдаемость вместо барьера: перечень называется строкой
журнала при подъёме.
- **Логин у провайдера переиспользуем**, и новый его владелец получает архив
прежнего. → Записывается ценой в спеку и в модель угроз; не допускать
переиспользования — работа провайдера. Подробно — Р5а.
- **Панель владельца остаётся вне разграничения.** Она и была вне его; барьер
переезжает с правила на литерал пути (обходимого подменой знака) на домен,
закрытый Authelia. → Обход `/%5f/` перестаёт существовать, но канонизация пути
этой работой не делается, и в модели угроз это надо переписать, а не вычеркнуть.
## Migration Plan
Стройка: на сервере данных нет, выкладка пойдёт с чистого листа, переносить
нечего.
1. Новый шаг схемы — колонка ключа `provider_login` с уникальным индексом,
необязательная почта, снятые настройки OAuth2 и **все правила доступа
коллекции пользователей, снятые в пустое**. Применённый
`202608120001_oidc_login` не трогается.
2. Конфиг: секция входа переписывается, образец — вместе с ней.
3. Контур: правило прокси и правило Authelia для домена сервиса заводятся в
`pet-project-server`. Это делает человек, и до этого сервис никого не узнаёт.
Откат: шаг схемы возвращает то, что было до него, кроме открытого создания
записи — его не возвращает и прежний шаг, по той же причине.
## Open Questions
Открытых не осталось: оба вопроса закрыты человеком на чекпоинте 2026-08-22 —
имя ключа настроек (Р4) и способ представиться без прокси (Р8).
@@ -0,0 +1,71 @@
## Why
Сервис ведёт вход своими руками: уводит человека к провайдеру, помнит выданное
состояние у браузера, меняет принесённый код на сессию и потом семь суток верит
выданной куке, ни разу больше не спросив провайдера. Отзыв доступа доходит до
сервиса только истечением этой куки — то есть с задержкой до недели, — а секрет
клиента ради обмена лежит и в конфиге, и в базе.
Обратный прокси между интернетом и сервисом уже спрашивает Authelia на **каждом**
запросе и называет пришедшего заголовком: трём соседним сервисам того же контура
он так и служит. Сервису достаточно этот заголовок прочитать, и весь собственный
протокол входа становится лишним.
## What Changes
- **BREAKING** Кто пришёл, сервис узнаёт из заголовка, поставленного обратным
прокси. Своего входа у него не остаётся вовсе: ни адреса, уводящего к
провайдеру, ни возврата, ни куки, ни выхода.
- **BREAKING** Заголовку верят только с адреса, объявленного доверенным.
Пришедший с любого другого адреса тем же заголовком не узнаётся никак.
- Учётная запись заводится сама, при первом обращении с новым именем, и
находится по нему же при каждом следующем. Имя из заголовка становится ключом
учётной записи и живёт своей колонкой.
- **BREAKING** Секция настроек входа переписывается целиком: адреса провайдера,
идентификатор и секрет клиента уходят, приходит перечень доверенных адресов.
- Секрет клиента исчезает и из настроек хранилища. Вместе с ним снимается изъятие
из инварианта «Секрет не покидает конфиг»: чтение файла базы больше не
равносильно чтению секрета.
- Отзыв доступа перестаёт ждать семи суток: Authelia судит каждый запрос, а
сервис назначенного срока сессии больше не держит вовсе.
- Панель владельца закрывается доменом, а не правилом на литерал пути, и обход
подменой знака в адресе перестаёт существовать. Половина этой работы живёт в
контуре выкладки, вне репозитория.
- Способ представиться на машине без прокси заводится заново: подставной
провайдер уходит вместе с протоколом.
## Capabilities
### New Capabilities
Новых нет: работа меняет то, как узнаётся пришедший, а не заводит новое
поведение.
### Modified Capabilities
- `access`: узнавание пришедшего переезжает с собственного входа у внешнего
провайдера на заголовок доверенного источника. Уходят требования о протоколе
входа, о куке сессии, о сроке её жизни, о выходе, о продлении и о секрете
провайдера в конфиге; приходят требования о доверенном источнике, о заголовке
как имени пришедшего и о заведении учётной записи первым обращением.
Требования о владельце записи, об открытых адресах и о том, как приложение
узнаёт вошедшего, остаются по существу и правятся в формулировках.
## Impact
- Транспорт HTTP: обработчики входа и возврата, слой предъявления куки, слой
запрета продления, корень `/auth` целиком.
- Хранилище: приведение настроек провайдера к конфигу при подъёме, новый шаг
схемы — колонка имени из заголовка, снятие настроек OAuth2 и правила создания
записи. Применённый шаг `202608120001_oidc_login` не переписывается.
- Настройки: секция входа в конфиге и в его образце. Имена ключей — необратимое,
и решение по ним принимает человек.
- Приложение: адрес, которым экран уводил ко входу.
- Инструменты: подставной провайдер OIDC уходит, на его место встаёт способ
представиться без прокси. На него опирается задача `dev-run-task`.
- Документы: модель угроз — периметр, недоверенный вход, разграничение доступа,
сдвиг про секрет в базе; `CLAUDE.md` — изъятие из инварианта о секрете;
устройство и схема хранилища.
- Вне репозитория: правило обратного прокси и правило Authelia для домена
сервиса живут в `pet-project-server`. Этой работой они не проверяются, но
требование к ним она обязана назвать.
@@ -0,0 +1,511 @@
# Ревью изменения `trusted-header-login`
## Сводка
- **Режим прогона:** по графу. **Метка:** `large`.
*Размер и сложность в задании триажу не переданы* — обоснование разметки
воспроизвести нечем, метка взята как объявленная.
- **База диффа:** `origin/master`. Изменение читалось вместе с непрослеженными
файлами (`cmd/devtools/`, `internal/adapter/repo/pocketbase/identity.go`,
`migrations/202608220001_trusted_header_login.go`,
`internal/controller/http/identity.go`, дельта-спеки change).
- **Состояние гейта:** зелёный целиком, кроме шага `dockerfile``hadolint`
даёт `DL3066` на `Dockerfile:94`. Отказ **унаследован** (воспроизведён на
чистом `origin/master`), долг объявлен строкой в `CLAUDE.md`, раздел «Гейт».
Новым красным шагом это изменение гейт не красит.
- **На входе:** 31 пронумерованная находка проходов + 4 пункта «дешевле
переделать до мерджа» от `architecture` + 1 замер без дефекта от `ops`.
**На выходе:** 3 + 4 = 7 в основных секциях, остальное — ниже, ничего не
выброшено молча.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | дельта-спеки change + `openspec/specs/` | разбор | `specs` | закрыта, 8 находок (5–12); в отчёт ушли 2, ещё 4 — ниже |
| autotests | `CLAUDE.md`, «Гейт» | — | `autotests` | закрыта, 4 находки (1–4); в отчёт не ушла ни одна самостоятельно, одна закрывается правкой №3 |
| conventions | `docs/conventions/` | разбор | `code` | закрыта, 8 находок (15–22); в отчёт ушли 2 |
| architecture | `docs/architecture.md` + `docs/passport.md` | доказательство | `architecture` | закрыта, 2 находки + 4 пункта «до мерджа»; в отчёт ушла 1 |
| security | `docs/security.md` | доказательство | `adversary` | закрыта, 6 находок (23–28); в отчёт ушли 3 |
| operations | `docs/architecture.md` «Эксплуатация» + `docs/database.md` | доказательство | `ops` | закрыта, 3 находки + 1 замер без дефекта; в отчёт не ушла ни одна, старшая — в гипотезах |
- **Тем без отчёта нет.** Тем без дома в плане нет.
- **`basics` не запускался** — своих тем проекта нет, все шесть разобраны
именными проходами.
- **Сигнал о заниженной метке:** `review-code` отработал и возражений по метке
**не подал**; `review-basics` не запускался, поднять метку было некому.
Молчание здесь — молчание одного корректора из двух, а не двух из двух.
---
## Блокирует мердж
### Запрос, отвергнутый ограничителем частоты, всё равно заводит учётную запись
- Файл: `internal/controller/http/identity.go:91`, `internal/controller/http/errors.go:219`
- Severity: major (`critical` не ставлю: инвариант `CLAUDE.md` этим не нарушен, а
путь ведёт к мусору в коллекции, а не к порче чужих данных)
- Confidence: high
- Оракул: **свой падающий тест, прогнан на этом прогоне.** Слой узнавания стоит
на `DefaultLoadAuthTokenMiddlewarePriority + 1` = **−1019**, ограничитель
библиотеки — на **1000** (`apis/middlewares_rate_limit.go:16`), то есть
узнавание отрабатывает **до** ограничителя. Прогон: 120 запросов выбирают
бюджет, следующие 50 запросов новыми логинами получают `429`**все 50**, — а
учётных записей в коллекции становится `2 → 52`.
- Последствие: ограничитель частоты не защищает **ничего** из того, что делает
слой узнавания. Каждый отвергнутый запрос — это как минимум чтение из базы
(поиск по логину идёт на каждом запросе по норме `access`), а с новым именем —
ещё и запись. Учётная запись, заведённая мусором, из сервиса не убирается:
спека `access` называет цену прямо — «слить их или убрать нечем». Барьер
«заголовок ставит прокси» этот путь сужает, но не закрывает: `docs/review.md`,
«Недоступно проверке», записывает поведение прокси как непроверяемое отсюда, и
строить на нём защиту базы значит держать один барьер вместо двух.
- Предложение: перенести слой узнавания на
`apis.DefaultRateLimitMiddlewarePriority + 1` (999), а `RequireUser` — на
`+ 2` (−998). Порядок «токен (−1020) → ограничитель (−1000) → узнавание (−999)
→ требование записи (−998) → предел тела (−990)» сохраняется целиком.
- Найдено проходом: `adversary` (25); подтверждено собственным прогоном триажа.
- Действие: **инлайн**
> **Ответ на первый вопрос задания — перестановка проверена запуском.** С
> приоритетами −999/−998 прогнан **весь** `go test ./...`: единственным красным
> остался мой временный тест на находку про общий бюджет (ниже, №4), все
> существующие проверки пакета `internal/controller/http` — включая оба критерия
> приёмки, победу предъявленного токена над заголовком и «проба здоровья
> учётной записи не заводит» — прошли. Заведение записи после `429` при этом
> пропало: `2 → 2`. Предел тела на −990 действительно остаётся после требования
> учётной записи, «отказ неузнанному до чтения тела» сохраняется.
>
> **Одно последствие перестановки задание не называет, и оно настоящее.** Сейчас
> `RequireUser` (−1018) отказывает неузнанному **до** ограничителя, и отказы
> бюджета не тратят. После переноса — тратят: замер на этом прогоне, 125
> запросов без заголовка, и следующий запрос узнанного человека получает `429`
> (до перестановки — `200`). Само по себе это правильнее (счёт отказов и есть
> работа ограничителя), но вместе с общим бюджетом (№4) оно даёт анониму,
> дотянувшемуся до контейнера, выключение сервиса для всех. Поэтому №1 и №4
> едут одной правкой, а не порознь.
### Ключ учётной записи правится рукой в панели, и архив уезжает следующему с этим именем
- Файл: `internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go:77-82`,
`internal/adapter/repo/pocketbase/panel.go:33-34`
- Severity: major
- Confidence: high
- Оракул: дословное требование дельта-спеки
`openspec/changes/trusted-header-login/specs/access/spec.md`: «**Ключ учётной
записи MUST не правиться ничем, кроме заведения самим сервисом.** Ни запросом
снаружи, ни рукой в панели». Плюс
`grep -rn "OnRecordUpdateRequest" --include=*.go internal/ cmd/` → единственная
привязка стоит на `migrations.RecordsCollection`, на `users` хука нет. Шаг
схемы закрывает правила API (`UpdateRule = nil`), а панель работает
суперпользователем — это записано комментарием самого шага, строки 48–50.
- Последствие: переписанный в панели `provider_login` отдаёт весь архив прежнего
владельца тому, кто придёт с этим именем следующим. Владелец записи
назначается один раз и не меняется — вернуть архив нечем. Правка своя,
сознательная, но необратимая и **молчаливая**: журнала событий у коллекции
пользователей нет.
- Предложение: развилка ниже.
- Найдено проходом: `specs` (6)
- Действие: **развилка**
> **Вопрос владельцу.** Требование спеки «ключ не правится рукой в панели»
> сегодня не держит ничто: правила API закрыты, но панель ходит
> суперпользователем. Три варианта:
> **(А)** завести хук `OnRecordUpdateRequest(users)`, отвергающий смену
> **непустого** `provider_login` — в проекте уже есть тот же приём для записей
> (`pbrepo.BindPanelRules`), цена — десяток строк и проверка;
> **(Б)** переписать требование, назвав панель доверенной и записав цену
> («ключ правится владельцем сервиса, откатить правку нечем»);
> **(В)** оставить как есть — тогда спека и код расходятся молча, и это
> расхождение переживёт мердж.
### Заведение учётной записи не оставляет в журнале ни строки, а поломка контура пишется на уровне, которого в бою нет
- Файл: `internal/controller/http/identity.go:101-108,128-151`,
`internal/adapter/repo/pocketbase/identity.go:52-87`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Запрос новым логином с доверенного адреса →
ответ `200`, учётная запись заведена, перехваченный журнал — **пустая строка**
(`""`). Дословное требование дельта-спеки `access`: «Исход узнавания MUST
оставлять строку журнала — и когда заголовок пришёл с недоверенного адреса, и
**когда учётная запись заведена**. […] Строка несёт адрес пира и идентификатор
учётной записи». Второй симптом: ветвь «более одного значения `Remote-User`»
(строки 103–106) пишет `Debug`, боевой уровень — `Info`
(`docs/conventions/logging.md`, «Расхождение: уровень зашит константой»).
- Последствие: сервис заводит учётные записи молча — владелец не отличит «никто
не заходил» от «завелось двадцать записей», а по спорной учётной записи не
скажет, когда и с какого адреса она появилась. Хуже второе: `docs/security.md`
называет прокси, добавляющий заголовок вместо замены, **главным** барьером
контура, и половину этой беды сервис закрывает сам — но закрывает **невидимо**:
строка о двух значениях в боевом журнале не появляется вовсе. Поломка контура,
которую сервис поймал, владельцу неотличима от тишины.
- Предложение: `logger.Info` на исходе «учётная запись заведена» — с адресом
пира и идентификатором записи, **без** значения заголовка (инвариант
«Содержимое записи остаётся приватным» и требование спеки «MUST не нести
значения заголовка»); уровень ветви «два значения» поднять до `Warn` — ровно
как у ветви недоверенного пира, по той же причине и с той же ценой.
- Найдено проходами: `specs` (5) и `adversary` (26) — две находки об одной
причине, слиты; **оракула у согласия проходов нет, оракул свой**.
- Действие: **инлайн**
---
## Стоит исправить сейчас
### Бюджет ограничителя частоты общий на весь сервис: один человек выключает сервис остальным
- Файл: `internal/controller/http/rate_limit.go:20-23`, `internal/controller/http/app.go:43-63`,
`internal/config/config.go:91-96`, `cmd/transcriber/main.go:238-246`
- Severity: major
- Confidence: high
- Оракул: **свой падающий тест.** 125 запросов первого человека выбирают бюджет,
**первый** запрос второго человека получает `429`. Причина — в исходниках
библиотеки: `checkRateLimit` берёт ключом `e.RealIP()`
(`apis/middlewares_rate_limit.go`), а `RealIP()` при пустом
`Settings.TrustedProxy.Headers` откатывается к адресу пира
(`core/event_request.go:40-75`). Пир теперь **всегда** обратный прокси.
- Последствие: объявленная сервисом частота опроса выводится из доли бюджета
(`pollBudgetShare = 8`, `PollIntervalMs = 4000`) в расчёте на то, что бюджет
делят немногие. Делят его **все**: восемь одновременных опросов выбирают его
целиком, и девятый человек получает `429` на пустом месте. Отказ шумный, но
причина невидима — по журналу он неотличим от собственной активности.
- **Дефект старше этой задачи** (правило заведено `spa-skeleton` 2026-08-15,
прокси перед сервисом стоял и тогда). Уточнение к формулировке прохода:
`docs/database.md:344` и `app.go:43-49` **не** утверждают обратного — там
прямо написано «бюджет считается по адресу спрашивающего, а не по учётной
записи», и даже назван случай «двое за одним домашним адресом делят его
пополам». Сломалось не правило, а **основание** правила: «адрес
спрашивающего» выродился в один адрес на всех, и число 120/60 выбиралось не
под это.
- Найдено проходами: `architecture` (13) и `adversary` (23); подтверждено
собственным прогоном.
- Действие: **развилка**
> **Ответ на второй вопрос задания: чинить здесь.** Не потому, что дефект
> этой задачи — он не её, — а потому, что правка №1 делает его достижимым для
> **анонима**: после переноса `RequireUser` за ограничитель отказы неузнанному
> начинают тратить общий бюджет (замерено на этом прогоне: 125 анонимных
> запросов → узнанный человек получает `429`). Мерджить №1 без ответа на №4
> значит завести новый путь к отказу сервиса.
>
> **Вопрос владельцу.** Чем считать бюджет ограничителя, когда весь трафик
> приходит с одного адреса:
> **(А)** заполнять `Settings.TrustedProxy.Headers` при подъёме (там же, где
> `ApplyAppRateLimit`) — тогда ключом станет адрес человека из
> `X-Forwarded-For`. Доверие к этому заголовку той же природы, что доверие к
> `Remote-User`, и опирается на тот же перечень адресов; цена — ещё одно
> требование к контуру, которое отсюда не проверить;
> **(Б)** признать бюджет общим на сервис: поднять числа под ожидаемое число
> людей, переписать `docs/database.md` и обоснование `pollBudgetShare`;
> **(В)** отложить в урожай и мерджить №1 с известным ухудшением — тогда
> анонимный поток через прокси выключает приложение всем.
### Негодное значение `Remote-Name` запирает человека в сервисе навсегда пятисоткой
- Файл: `internal/adapter/repo/pocketbase/identity.go:71-87,147-159`,
`internal/controller/http/identity.go:133-147`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Запрос с доверенного адреса, `Remote-User: namebearer`,
`Remote-Name` из 5000 знаков → ответ `500`
`{"error_code":"internal","message":"Внутренняя ошибка сервиса"}`, в журнале
`level=ERROR msg="Failed to resolve account by login header" error="failed to
create user account: name: Must be no more than 255 character(s).."`.
Значение заголовка в ответ и в журнал при этом **не** уехало — инвариант
приватности цел.
- Последствие: приёму подвергается только логин (`AcceptProviderLogin`), а имя и
почта уезжают в колонку как есть. Человек, чьё имя у провайдера длиннее 255
знаков, получает `500` на **каждом** запросе и в сервис не попадёт никогда;
владелец получает `ERROR` на каждый такой запрос. Смежный симптом той же
причины: разбор отказа судит по **наличию колонки** в `validation.Errors`, а не
по причине отказа — негодный адрес почты неотличим от занятого, и запись молча
заводится без почты вместо честного разбора.
- Предложение: привести имя и почту к годным значениям в одном месте с логином —
имя усечь до предела колонки, негодную почту не ставить вовсе. Этой же правкой
закрывается непокрытая ветвь «отказ хранилища → отказ сервиса»
(`identity.go:144-146`), которую `autotests` называет единственным намеренным
исключением из «слой отказа не выдаёт»: у неё появляется достижимый и
проверяемый вход.
- Найдено проходами: `specs` (7, 10), `code` (15, 16), `adversary` (24) — пять
формулировок, две причины, одно место правки; слиты. Согласие пяти проходов
`Confidence` не повышает — оракул свой.
- Действие: **инлайн**
### Образец конфига велит верить всей подсети docker: любой контейнер на хосте входит под любым именем
- Файл: `config.example.toml:69-77`
- Severity: major
- Confidence: medium (путь не построен: второго контейнера в прогоне нет)
- Оракул: положение `docs/security.md:109` дословно — источник заголовков есть
«Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного
адреса**». Образец задаёт этим адресом `172.16.0.0/12`, то есть весь
умолчательный диапазон docker: под него попадает любой контейнер на хосте, а
не только Caddy.
- Последствие: барьер узнавания держится **только** на адресе пира (кук и
токенов сервис не выдаёт). Любой сосед по хосту — а хост держит пет-проекты, а
не один сервис — ставит `Remote-User: <чужой логин>` и получает чужой архив
целиком, молча. Класс риска в `security.md` назван, ширина перечня — нет.
- Предложение: развилка ниже.
- Найдено проходом: `adversary` (27)
- Действие: **развилка**
> **Вопрос владельцу.** Чем сузить перечень доверенных адресов до самого
> прокси:
> **(А)** выделенная сеть compose с фиксированной подсетью под пару
> «прокси ↔ сервис» — правка живёт в `pet-project-server`, здесь остаётся
> значение образца и строка требования к контуру;
> **(Б)** статический адрес прокси в сети docker — то же, но проще и хрупче;
> **(В)** принять `/12` как цену и записать её строкой в `docs/security.md`
> рядом с уже названным классом — тогда это осознанная цена, а не умолчание
> образца.
### Три конвенции проекта описывают снятый механизм, и одна противоречит себе внутри файла
- Файл: `docs/conventions/web-ui.md:107-109`, `docs/conventions/config.md:60-64` против `:99`,
`docs/conventions/logging.md:34-37`
- Severity: minor
- Confidence: high
- Оракул: чтение файлов на этом прогоне. `web-ui.md`: «**Сессия живёт кукой
`transcriber_session`**» — кук сервис больше не ставит вовсе. `config.md`
строка 60–64: «Секретов в этой секции больше нет», строка 99 в том же файле
по-прежнему перечисляет `auth.client_secret` среди секретов. `logging.md`
строки 34–37 описывают расхождение у пакета `cmd/oidcstub`, который этим же
изменением удалён (`git status``D cmd/oidcstub/main.go`).
- Последствие: конвенции — канон проекта, по ним работает следующая задача.
Запись про куку заставит следующего агента искать куку и объяснять её
отсутствие; запись про `client_secret` заставит охранять секрет, которого нет.
Машина этого не ловит: согласованность документов между собой и с кодом судят
агенты, и звать их надо руками (`CLAUDE.md`, «Гейт»).
- Предложение: снять три записи, поправить перечень секретов в `config.md`.
- Найдено проходом: `code` (20)
- Действие: **инлайн**
---
## Гипотезы без доказательства
Понижено и не влезло в потолок; каждая с причиной понижения.
- **Откат бинаря поверх шага схемы `202608220001` обрывает вход** (`ops`, 29;
было `major`). Оракул у прохода настоящий — падающий тест на восстановленном
старом коде во временном worktree, `401` вместо `302`. Понижаю до `minor`
**по стадии и по перекрытию**: (1) стадия — стройка, сервис на сервере
остановлен, данных нет, откатываться не с чего; (2) `docs/architecture.md:142`
уже объявляет откат неработающим начиная с шага `202608140002` — то есть любой
бинарь старше 2026-08-14 и так не поднимается; новое окно отката — только
версии между 14 и 22 августа, ни одна из которых не выкладывалась; (3) старый
бинарь на новом конфиге и вовсе не стартует, если владелец убрал ключи OIDC по
процедуре из «Эксплуатации» — тогда отказ громкий, а не молчаливый.
**Ответ на третий вопрос задания: да, стадия меняет серьёзность.** Правка тем
не менее дешёвая и уместная — строка в `docs/architecture.md`, «Эксплуатация»,
рядом с уже стоящей про `202608140002`: не потому что откат сегодня возможен, а
потому что порог перехода там принято называть прямо.
- **Уникальный индекс по `provider_login` не частичный** (`code`, 17). Оракул
прохода — исходники библиотеки (`core/collection_model.go:648-671`, индекс
собирается без `WHERE`, сосед по коллекции — `WHERE email != ''`). Понижаю:
сервис пустого логина в колонку не кладёт никогда (`AcceptProviderLogin`
отвергает), коллекция пользователей на выкладке пуста, и последствие сводится
к «в панели рукой не завести вторую запись без логина». Промах будущего, не
дефект сегодня.
- **Предел логина считается в байтах, колонка — в знаках** (`specs`, 8;
`code`, 18). Проверено чтением: `len(login) > 255` в
`identity.go:174` против `Max: 255` у текстовой колонки. Понижаю: перекос
только в сторону строгости, кириллический логин длиннее 127 знаков у Authelia
не встречается, следа `Debug` хватает. Пути нет — гипотеза.
- **Склейка значений заголовка запятой обходит защиту «два значения»**
(`adversary`, 28). Пути нет и построить его отсюда нечем: `net/http` заголовки
запятой не сливает, а прокси, который сливает, — ровно тот класс, который
`docs/review.md` относит к «не проверит ни один проход». Последствие при этом
было бы неприятным и тихим (человек попадает в **новую** пустую учётную
запись вместо своей), поэтому строка оставлена, а не выброшена.
- **Откат шага схемы не восстанавливает `OAuth2.Enabled` и минирует записи без
почты** (`specs`, 9; `ops`, 30). Оракул у `ops` есть — прогон на временной
базе, `email: cannot be blank`. Понижаю по тому же основанию, что и №29: `down`
на сервере не зовётся, а применённый шаг не переписывается по инварианту.
- **Отказ хранилища при узнавании не покрыт ничем** (`autotests`, 2). Не
выведена отдельной строкой: правка про негодное имя даёт этой ветви
достижимый вход, и тест на неё пишется той же правкой. Если владелец выберет
не чинить негодное имя — эта строка возвращается самостоятельной находкой.
- **Непокрытые ветви `retryAfterConflict` и `web/src/api.ts`** (`autotests`, 1,
3, 12). Понижаю по записанному решению проекта: `CLAUDE.md`, «Гейт» —
«покрытие изменённых строк не считается ничем». Своего `api.test.ts` у нового
`web/src/api.ts` действительно нет (проверено `ls web/src/`), и это стоит
завести — но это задача, а не блокирующая находка.
---
## Promote candidates
Претензии на правило, а не на этот код.
- **Поле `peer` заведено мимо словаря имён журнала** (`code`, 21).
`docs/conventions/logging.md:99` требует точечной иерархии для системных
доменов (`http.*`, `ext.*`, `webapp.*`), а в файле уже стоит *Расхождение* с
перечнем самозаведённых имён. Кандидат в конвенцию: внести `http.peer_addr` в
таблицу полей — тогда следующий агент возьмёт имя из словаря, а не сочинит
третье.
- **Третий способ вывода в репозитории** (`code`, 22). `cmd/devtools` печатает
через stdlib `log`, тогда как `logging.md` знает два. Записанной конвенции об
оснастке у проекта нет — значит это кандидат в правило, а не нарушение.
- **Единственный дом имён заголовков `Remote-*`** (`architecture`, 14).
`cmd/devtools/main.go:92-97` пишет `"Remote-User"`, `"Remote-Name"`,
`"Remote-Email"` своими литералами, тогда как нормативные константы лежат в
`internal/controller/http/identity.go:23-27`. Правка на две строки, но правило
общее — «нормативное имя объявляется один раз» — и его стоит записать, а не
чинить точечно. Смежный вопрос `docs/review.md`, «Вопросы по темам»: не завёл
ли инструмент разработчика второй дом тому, что уже есть в проверках.
- **Второй словарь текстов на клиенте** (`code`, 19). Половина находки —
ложноположительная: `docs/conventions/web-ui.md:118` **сам** разрешает
клиентский текст «связи нет» состоянием, а не ошибкой. Настоящий остаток —
расхождение текстов: сервер на `401` отвечает «Требуется вход» про вход,
которого у сервиса больше нет, а экран показывает свой текст поверх. Кандидат
в правило: конвенция сегодня не различает «текст под код ответа» и «текст под
состояние клиента», и на этом различии находка и разошлась.
---
## Урожай
Настоящее, но не для этого мерджа.
- **Бюджет ограничителя частоты по человеку, а не по адресу** — если владелец
выберет вариант (В) на развилке №4.
- **Секция конфига называется `[auth]` при отсутствии всякого входа**
(`architecture`). Имя ключа конфига — из перечня необратимого в `CLAUDE.md`
(«спрашивается у человека всегда»), но **сегодня стадия стройки**: на сервере
ничего нет, и переименование стоит ноль. После первой выкладки — уже решение
человека. Если переименовывать, то сейчас.
- **Колонка почты без единого читателя** (`architecture`). Почта пишется при
заведении и не читается ничем. Не дефект: спека объявляет её необязательной и
берёт «при заведении». Но колонка без читателя — долг, который стоит либо
оправдать спекой, либо снять.
- **Корень `/auth` перестал быть зарезервированным** (`architecture`). Проверено:
упоминаний `/auth` в `app.go` больше нет. Освободившееся адресное пространство
стоит либо занять, либо назвать свободным в спеке `webapp`.
- **Свой тест у `web/src/api.ts`** — файл новый, разбор отказов в нём есть,
проверки нет.
- **Строка про порог отката в `docs/architecture.md`, «Эксплуатация»** — см.
гипотезу №29.
---
## Границы покрытия
### План: темы, дома, глубины
Воспроизведён таблицей в сводке выше целиком. Тем без дома в плане нет, тем без
отчёта нет.
### Что запускалось
- Режим: по графу, метка `large`. Запущены `specs`, `autotests`, `code`,
`architecture`, `adversary`, `ops`.
- **`basics` не запускался** — своих тем проекта у него на этом прогоне нет, все
шесть тем разобраны именными проходами. Следствие: сигнал о заниженной метке
мог подать только `review-code`, и он его не подал.
### Чего не хватило триажу, и это находки о прогоне
- **Блоков «Coverage of this pass» в сырых выводах нет ни у одного прохода.**
Контракт находок требует их у каждого, и сводить мне было нечего: строку «что
этот проход не мог проверить в принципе» я по каждому проходу воспроизвести не
могу. Ниже — только то, что выводится из плана и из документов проекта.
- **Сработавшие потолки ни один проход не назвал.** Сколько находок проход
показал, каков был его потолок и что осталось за срезом — не сообщено никем.
Значит утверждать «проход показал всё, что нашёл» нельзя ни про один из шести.
- **Размер и сложность изменения триажу не переданы** — только метка.
Обоснование разметки в отчёте воспроизвести нечем.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке». **Два списка, и они не сливаются.**
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: **поведение настоящей Authelia и правило обратного прокси на домен
сервиса.** С 2026-08-22 от прокси зависит **весь** барьер: он обязан заголовки
`Remote-*` перезаписывать, а не пропускать пришедшие. Правило живёт в
`pet-project-server`, и отсюда его не проверить. Прямо на этом висят находки
№1 (насколько узок путь к заведению мусорных записей), №6 (ширина перечня
доверенных адресов) и гипотеза про склейку запятой;
- `security`: поведение браузера с куками — своих кук сервис больше не ставит,
класс сузился до кук панели хранилища.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe` — решение и цена в
`ADR-2026-08-11-stub-adapters-in-tests.md`;
- **работа сервиса с настоящими внешними собеседниками** в остатке: за настоящие
SpeechKit и Object Storage живой прогон не отвечает — ключи выдуманные,
распознавание подменяется в коде. Вход при этом с 2026-08-22 живой прогон
проверяет целиком (`cmd/devtools proxy`), и это сдвиг в другую сторону.
Плюс общее, что не проверяет ни один прогон: история инцидентов, поведение под
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
текущее поведение и вопрос «а нужна ли эта функциональность вообще».
### Каких документов проекта не хватило
- `docs/review.md`, «Типовые ложноположительные» — **есть и не пуст**, отсев по
нему выполнен. Одна находка (`code`, 19) отсеяна наполовину по общим
критериям, а не по нему: разрешение клиентского текста для состояния «связи
нет» стоит в `docs/conventions/web-ui.md`, а не в разделе
ложноположительных.
- `CLAUDE.md`, «Инварианты» — есть; ни одна находка этого прогона до `critical`
по этому основанию не поднята, потому что ни одна инвариант не нарушает:
проверено поимённо для приватности содержимого (значение заголовка в журнал и
в ответ не уходит — замерено), для секрета в конфиге (секретов в секции
`[auth]` больше нет) и для запрета переписывать применённый шаг схемы.
- **Прочих пробелов в документах проходы не назвали** — но назвать их было
некому: блоков границ у проходов нет (см. выше). Отсутствие строки здесь не
означает, что документов хватило.
### Четыре строки, которых не принёс ни один проход
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
его не открывает. Изменение заводит `ADR-2026-08-22-login-by-trusted-header.md`,
и сверку его с остальными записанными решениями — включая те, что это
изменение помечает (`ADR-2026-08-12-oidc-exchange-via-own-route`,
`ADR-2026-08-12-session-without-refresh`), — конвейер ревью не делал.
Расхождение изменения с записанным решением ловит скилл
`av-dev:doc-healthcheck`, и звать его надо руками.
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
процессный. Всякое число в этом отчёте снято на этом прогоне: приоритеты
слоёв взяты из исходников библиотеки в `GOMODCACHE`, счёт учётных записей и
коды ответов — из прогонов, приложенных к находкам.
3. **Поимённая сверка с руководствами по стилю Go и Vue не задавалась ни одним
проходом.** Различение «идиоматично против распространено» на этом прогоне не
спрашивал никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру.
«Не знаю, чего не знаю» про схему узнавания по доверенному заголовку никто не
доставал.
Пятой строки — про метку `small` — на этом прогоне нет: метка `large`, дома тем
`security`, `operations` и `architecture` открывались.
### Что делал сам триаж
- Прочитаны: дифф изменения, `CLAUDE.md`, `docs/review.md` целиком,
`docs/security.md`, `docs/architecture.md` «Эксплуатация», `docs/database.md`
«Настройки с числовым значением», три конвенции, дельта-спека `access`,
исходники `pocketbase@v0.39.10` (`apis/middlewares*.go`,
`core/event_request.go`).
- Запущено во временном файле проверок пакета `internal/controller/http`
(удалён после прогона, дерево восстановлено, `go build ./...` и
`go test ./internal/controller/http ./internal/adapter/repo/pocketbase`
зелёные):
- заведение учётных записей запросами, ответившими `429` (`2 → 52`);
- общий бюджет ограничителя между двумя людьми;
- `500` и `ERROR` на длинном `Remote-Name`;
- пустой журнал на заведении учётной записи;
- расход бюджета анонимными отказами до и после перестановки приоритетов
(`200``429`).
- Перестановка приоритетов на −999/−998 применена **временно** и откачена;
на ней прогнан весь `go test ./...`. Код изменения не правился.
@@ -0,0 +1,674 @@
## ADDED Requirements
### Requirement: Пришедшего называет доверенный источник
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
барьер вовсе.
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
требованием учётной записи на адресах приложения.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает
хранилище, а на новом имени ещё и пишет в него; выполненное раньше ограничителя,
оно работало бы на запросах, которые тот уже отверг, и поток отвергнутых
обращений заводил бы учётные записи, которые потом не убираются ничем.
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
Область — корень приложения и адрес, которым хранилище выдаёт короткий токен
файла; она MUST выводиться из объявленного адресного пространства сервиса, а не
перечисляться вторым списком. Собственная поверхность хранилища под узнавание
MUST не подпадать: правка учётной записи, её чтение и перечисление коллекции
пользователей остаются недостижимыми для узнанного, потому что ключ учётной
записи лежит там обычной колонкой, а правило правки у коллекции — библиотечное и
разрешает править свою запись. Расширение области на всё дало бы узнанному
переписать себе ключ на чужое имя и забрать чужой архив.
Сужение области закрывает и вторую вещь: узнавание MUST не срабатывать на пробе
здоровья, на метриках и на ресурсах приложения. Иначе запрос за каждой картинкой
стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё.
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
без этой нормы все неназванные собрались бы в одну учётную запись с общим
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
бы вход анониму. Значение сверх объявленного предела длины и значение с
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
считает колонка.
Отказ хранилища при узнавании MUST кончаться отказом сервиса, а не молчаливым
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
настройке журнала: обе строки означают поломку контура, а поломка, записанная
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
которого никто не может войти, не отличит своей поломки (перечень доверенных
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
нести значения заголовка.
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
MUST не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же:
**короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по
ссылке на файл записи; его нормирует capability `storage`, а срок его жизни
назначается числом и живёт там, где проект держит числовые настройки. На этот
срок — и только на него — отзыв доступа до файловой ссылки не доходит.
В остальном смысл именно таков: отзыв доступа судит провайдер на каждом
обращении, а не однажды выданный срок.
Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок:
владелец панели предъявляет свой, и подмена его учётной записью пользователя
отобрала бы у него панель посреди работы.
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
адресом его почты.
#### Scenario: Заголовок с доверенного адреса узнаёт человека
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
- **THEN** запрос идёт от имени учётной записи с этим значением
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
- **GIVEN** адреса источника в перечне доверенных нет
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
- **THEN** ответ имеет код `401`
- **AND** учётной записи с этим значением не появляется
#### Scenario: Предъявленный токен побеждает заголовок
- **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен
хранилища
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается предъявитель токена
#### Scenario: Протухший токен узнаванию не мешает
- **GIVEN** запрос несёт заголовок `Remote-User` и негодный либо протухший токен
хранилища
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается названный заголовком
#### Scenario: Пустой заголовок не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Два значения одного заголовка не узнают никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Значение сверх предела длины не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Поверхность хранилища узнаванию не подпадает
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он правит запись коллекции пользователей собственным адресом
хранилища
- **THEN** правка не проходит
#### Scenario: Проба здоровья учётной записи не заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
- **THEN** учётной записи не появляется
#### Scenario: Недоверенный источник виден в журнале
- **WHEN** запрос с заголовком приходит с недоверенного адреса
- **THEN** журнал несёт строку об этом исходе с адресом пира
- **AND** значения заголовка в ней нет
#### Scenario: Сервис не ставит браузеру куки
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения проходит с заголовком
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
### Requirement: Учётная запись заводится первым обращением
Сервис SHALL заводить учётную запись при первом обращении с новым значением
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
коллекции пользователей.
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
пределу колонки и чистится от управляющих знаков, негодный адрес почты
отбрасывается. Негодное значение необязательного поля MUST не отменять
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе
всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы
карточку человека молча, посреди его работы.
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
с чужим адресом досталось бы чужой записи.
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
Ни запросом снаружи, ни рукой в панели: переписанный ключ отдаёт архив
следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи
назначается один раз и не меняется. Правило доступа коллекции пользователей MUST
закрывать правку записи снаружи наглухо, и MUST это держать схема, а не область
действия узнавания: защита, стоящая на том, что до поверхности хранилища никто не
дотянется, однажды уже оказалась случайной.
Одновременные первые обращения одним значением MUST кончаться одной учётной
записью: уникальность держит схема, а не порядок обращений.
**Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой
колонке — это гонка двух первых обращений одним именем, и он MUST кончаться
повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес
почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться
заведением записи **без почты**: она необязательна. Без этого разреза второй
человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск
по имени снова ничего не находит.
Цена ключа называется целиком, обеими сторонами. Переименование пользователя у
провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней;
слить их или убрать нечем — владелец записи не меняется, а учётная запись с
записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек,
которому провайдер выдал логин ушедшего, при первом обращении попадает в
существующую запись и получает весь её архив. Не допускать переиспользования —
работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта
цена принимается, а не обходится.
#### Scenario: Первое обращение заводит запись
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит запрос с заголовком `Remote-User`
- **THEN** учётная запись появляется
- **AND** запрос идёт от её имени
#### Scenario: Повторное обращение попадает в ту же запись
- **GIVEN** учётная запись заведена первым обращением
- **WHEN** приходит второй запрос с тем же значением заголовка
- **THEN** новой учётной записи не появляется
- **AND** запрос идёт от имени прежней
#### Scenario: Разным значениям — разные записи
- **WHEN** приходят запросы с двумя разными значениями заголовка
- **THEN** заводятся две учётные записи
- **AND** записи одного не видны другому
#### Scenario: Имя не переписывается вторым обращением
- **GIVEN** учётная запись заведена с одним значением `Remote-Name`
- **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name`
- **THEN** имя учётной записи остаётся прежним
#### Scenario: Два одновременных первых обращения дают одну запись
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** два запроса с одним значением заголовка приходят одновременно
- **THEN** в коллекции пользователей появляется ровно одна запись
- **AND** оба запроса идут от её имени
#### Scenario: Занятая почта не мешает завести запись
- **GIVEN** учётная запись с этим адресом почты уже заведена
- **WHEN** приходит первое обращение с другим `Remote-User` и тем же
`Remote-Email`
- **THEN** заводится своя учётная запись
- **AND** адреса почты у неё нет
#### Scenario: Ключ учётной записи не правится и рукой в панели
- **GIVEN** учётная запись заведена
- **WHEN** её ключ меняют сохранением записи мимо адресов приложения
- **THEN** сохранение отвергается, а ключ остаётся прежним
#### Scenario: Негодное имя не отменяет заведения
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит обращение с именем длиннее предела колонки
- **THEN** учётная запись заводится, а имя обрезано по пределу
#### Scenario: Негодная почта отбрасывается, а не отменяет заведение
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит обращение с адресом почты, не похожим на адрес
- **THEN** учётная запись заводится без почты
#### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит
- **GIVEN** бюджет ограничителя частоты выбран
- **WHEN** приходит обращение с новым значением заголовка
- **THEN** ответ несёт отказ ограничителя
- **AND** учётной записи не появляется
#### Scenario: Заведение учётной записи видно в журнале
- **WHEN** приходит первое обращение с новым значением заголовка
- **THEN** журнал несёт строку о заведении с идентификатором записи
- **AND** значения заголовка в ней нет
#### Scenario: Ключ учётной записи снаружи не правится
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** он правит ключ своей учётной записи запросом к хранилищу
- **THEN** правка не проходит, а ключ остаётся прежним
### Requirement: Доверенный источник объявлен настройкой
Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт,
когда перечень пуст либо его строки не читаются как адрес или подсеть. Пустой
перечень значит «не верить никому»: сервис поднялся бы никого не узнающим, а
узнать об этом было бы неоткуда.
Отказ старта MUST называть имя ключа. Ни адресов провайдера, ни идентификатора
клиента, ни секрета клиента в конфиге MUST не быть: менять код больше не на что,
и секрет уходит из конфига вместе с протоколом.
Перечень MUST называться строкой журнала при подъёме. Сервис, никого не узнающий
из-за неверного перечня, иначе неотличим от сервиса, до которого заголовок не
доходит вовсе, — а это разные поломки в разных местах.
#### Scenario: Пустой перечень роняет старт
- **WHEN** сервис поднимается с пустым перечнем доверенных адресов
- **THEN** старт кончается отказом
- **AND** отказ называет имя ключа
#### Scenario: Негодная строка перечня роняет старт
- **WHEN** сервис поднимается с перечнем, где строка не читается как адрес или
подсеть
- **THEN** старт кончается отказом
#### Scenario: Перечень виден в журнале подъёма
- **WHEN** сервис поднимается с заполненным перечнем
- **THEN** журнал подъёма называет доверенные адреса
## MODIFIED Requirements
### Requirement: Иных способов открыть сессию нет
Сервис SHALL оставить собственные адреса входа хранилища неработающими: ни один
из них MUST не давать доступа и MUST не менять учётной записи. Собственное
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть
выключены настройкой коллекции.
**Закрывается не только вход, но и правка.** Перечисление, чтение, создание,
правка и удаление записи коллекции пользователей MUST быть закрыты правилами
доступа — то есть оставлены пустыми, что у хранилища означает «только владелец
панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих
пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с
кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть
защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей
записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил,
панель работает суперпользователем, своих экранов профиля сервис не заводит —
закрытие не стоит ничего.
Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то
нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования узнавание по заголовку
обходится двумя запросами: завести себе запись, войти по паролю, предъявить
полученное.
Отдельная цена у открытого создания записи — захват учётной записи: запись,
заведённая посторонним под чужим именем, досталась бы первому же настоящему
обращению с этим именем.
Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при
первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему
не судья.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** запрос снаружи создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Обращение с заголовком запись заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит с доверенного адреса
- **THEN** учётная запись появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а доступа не открывается
#### Scenario: Обмен кода у провайдера недоступен
- **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции
пользователей
- **THEN** ответ несёт отказ, а доступа не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
#### Scenario: Правка учётной записи снаружи закрыта
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу
- **THEN** ответ несёт отказ, а запись остаётся прежней
#### Scenario: Перечисление учётных записей закрыто
- **GIVEN** человек узнан
- **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу
- **THEN** ответ несёт отказ
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя.
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
не убрать. Требование того же рода, что и запрет писать имя файла в хранилище:
там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым
довольно назваться, чтобы стать этим человеком.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
заголовка — тоже: это логин человека у провайдера.
Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом,
доступа сам по себе не даёт и без него путь запроса не прослеживается.
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
#### Scenario: Адреса почты нет в журнале
- **WHEN** приходит первое обращение и учётная запись заводится
- **THEN** адрес почты не встречается ни в одной журнальной записи
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
сервиса — настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и правило, настроенное слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
жизни этого значения. Теперь отзыв действует со следующего запроса.
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
прокси
- **THEN** доступ открывается, а учётная запись заводится, если её не было
- **AND** сервис не спрашивает у заголовков ничего сверх имени, показного имени
и адреса почты
#### Scenario: Отзыв у провайдера действует со следующего запроса
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
здоровья, ни у сборщика метрик учётной записи нет, и требование узнавания
остановило бы наблюдение за сервисом.
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
адреса не несут.
Заголовок, пришедший с недоверенного адреса, MUST не менять их ответа: узнавание
отказа не выдаёт, и посторонняя строка в запросе не вправе гасить наблюдение.
#### Scenario: Проба здоровья доступна неузнанному
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ имеет код `200`
#### Scenario: Метрики доступны неузнанному
- **WHEN** запрос приходит на `GET /metrics` без заголовка
- **THEN** ответ имеет код `200`
#### Scenario: Чужой заголовок наблюдения не гасит
- **WHEN** запрос на `GET /health` приходит с недоверенного адреса с заголовком
`Remote-User`
- **THEN** ответ имеет код `200`
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и
норму эту держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
- **GIVEN** человек узнан и принял запись
- **WHEN** он спрашивает карточку этой записи
- **THEN** ответ несёт данные записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним узнанным
- **WHEN** её карточку спрашивает другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится узнанный предъявитель
#### Scenario: Ничью запись завести нечем
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** карточку каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
### Requirement: Приложение узнаёт вошедшего
Сервис SHALL отдавать приложению сведения о том, кто пришёл, — `GET /app/me` — и
MUST отвечать отказом `401`, когда пришедший не узнан. Своей страницы со
скриптом, которой сервер отрисовал бы имя пришедшего, у сервиса нет: приложение
собирает разметку само и пришедшего узнаёт ответом.
Заголовок ставит прокси, и прочитать его из браузера приложение не может вовсе —
этот адрес единственный способ узнать, кто пришёл.
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
ему выходить наружу наравне с журналом.
#### Scenario: Пришедший узнан
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** приложение спрашивает, кто пришёл
- **THEN** ответ несёт идентификатор его учётной записи
#### Scenario: Пришедший не узнан
- **WHEN** приложение спрашивает, кто пришёл, без заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт учётной записи
#### Scenario: Адреса почты в ответе нет
- **GIVEN** пришедший узнан, и у его учётной записи есть адрес почты
- **WHEN** приложение спрашивает, кто пришёл
- **THEN** адреса почты в ответе нет
### Requirement: Приложение отдаётся без сессии
Сервис SHALL отдавать разметку приложения и её ресурсы неузнанному. Перечень
адресов, открытых без узнавания, пополняется ими: прежде в нём стояли только
проба здоровья и метрики.
Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал,
до приложения не доходит вовсе. Разметка при этом обязана отдаваться и ему —
иначе неудача узнавания выглядела бы поломкой сервиса, а не отказом входа. Цена
открытости названа здесь же и невелика: ни разметка, ни ресурсы содержимого
записей не несут — они одинаковы для всех и собираются до всякого запроса.
Открытость MUST не касаться данных: всякий адрес под корнем приложения
по-прежнему требует узнанного предъявителя, и приложение, открытое неузнанным,
не получает ни одной записи.
#### Scenario: Разметка доступна неузнанному
- **WHEN** запрос приходит на корень сервиса без заголовка
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Ресурс приложения доступен неузнанному
- **WHEN** запрос приходит на ресурс приложения без заголовка
- **THEN** ответ имеет код `200`
#### Scenario: Данные неузнанному не отдаются
- **GIVEN** приложение открыто без заголовка
- **WHEN** оно спрашивает список записей
- **THEN** ответ имеет код `401`
## REMOVED Requirements
### Requirement: Вход через внешнего провайдера
**Reason**: Сервис больше не ведёт входа. Собственный адрес, уводящий к
провайдеру, адрес возврата, сверка состояния, проверочный код PKCE и обмен кода
средствами хранилища убраны целиком: кто пришёл, называет обратный прокси,
сходивший к провайдеру на **каждом** запросе.
**Migration**: Узнавание нормирует требование «Пришедшего называет доверенный
источник». Правило провайдера переезжает с клиента OIDC на домен сервиса и живёт
в настройках выкладки.
### Requirement: Сессия предъявляется кукой
**Reason**: Сессии у сервиса нет вовсе. Кука `transcriber_session`, кука
состояния входа `transcriber_login` и слой, перекладывающий значение куки в
заголовок хранилища, убраны: узнавание идёт заголовком на каждом запросе, и
значения, переживающего запрос, сервис не выдаёт.
**Migration**: Требование «Пришедшего называет доверенный источник» — там же
записан запрет выдавать браузеру что-либо, переживающее запрос.
### Requirement: Сессия переживает перезапуск сервиса
**Reason**: Переживать перезапуск нечему. Узнавание не опирается ни на значение,
выданное однажды, ни на секрет подписи: заголовок приходит с каждым запросом.
**Migration**: Не требуется — свойство выполняется по построению.
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
**Reason**: Срока нет, потому что нет самой сессии. Он существовал ровно затем,
чтобы отзыв доступа у провайдера когда-нибудь дошёл до сервиса; теперь провайдер
судит каждый запрос, и отзыв действует со следующего. Запрет продления и способ
закрыть чужие сессии немедленно теряют предмет вместе со сроком.
**Migration**: Отзыв доступа нормирует требование «Кого пускать, решает
провайдер», сценарий про следующий запрос.
### Requirement: Выход прекращает доступ
**Reason**: Выхода у сервиса нет: обесценивать нечего и куку убирать неоткуда.
Выходят у провайдера, и со следующего запроса прокси заголовка уже не поставит.
**Migration**: Требование «Кого пускать, решает провайдер».
### Requirement: Секрет провайдера живёт в конфиге
**Reason**: Секрета клиента у сервиса больше нет: обменивать код не на что.
Вместе с ним из конфига уходят адреса провайдера и идентификатор клиента, а из
настроек коллекции пользователей — приведение их к конфигу при подъёме. Отсюда же
снимается изъятие из инварианта «Секрет не покидает конфиг»: чтение файла базы
больше не равносильно чтению секрета клиента.
**Migration**: Настройку узнавания нормирует требование «Доверенный источник
объявлен настройкой»; проверка целостности настройки на старте сохраняется там.
@@ -0,0 +1,100 @@
## MODIFIED Requirements
### Requirement: Отказ называет причину, а не место
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
разнице кодов перебирается список заведённых записей;
- узнанный предъявитель без учётной записи пользователя — `403`;
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
отвечать чужая и ничья запись;
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
негодный размер страницы;
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у
записи ещё нет;
- отказ хранилища и всякая неназванная причина — `500`.
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места
нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую
базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как
«моя запись пропала», а второе не говорит ему ничего.
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
все три `400`, — а приложению надо решать, предлагать ли повтор и что показать
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
задача экрана переписала бы контракт, согласованный здесь один раз.
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и
выбранные кодом они стали бы контрактом молча:
- поля тела: `error_code` и `message`;
- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`,
`bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`.
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на
адресах приложения две, а самый частый отказ у человека на мобильной сети —
«запись больше потолка» — приходит телом библиотеки, без кода и без предела
числом.
Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
журнале аварию там, где её нет.
Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
остаётся в журнале владельца сервиса.
#### Scenario: Сбой хранилища виден как сбой
- **GIVEN** хранилище отвечает отказом драйвера на чтение записи
- **WHEN** владелец спрашивает свою запись
- **THEN** ответ имеет код `500`
- **AND** тела записи в ответе нет
#### Scenario: Негодная запись видна как негодная
- **GIVEN** источник метаданных не может прочитать присланную запись
- **WHEN** отправитель шлёт её приёмом
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** причина отказа в тело ответа не попадает
#### Scenario: Отказ по пустому владельцу
- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет
- **WHEN** он шлёт запись приёмом
- **THEN** ответ имеет код `403`
#### Scenario: Форма тела одна на всех ветвях отказа
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
отсутствию учётной записи и по сбою хранилища
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
полями
- **AND** код отказа принадлежит закрытому перечню
- **AND** ни одно из них не содержит сырого текста ошибки
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
- **GIVEN** заведена запись
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
неизвестному идентификатору
- **THEN** оба ответа имеют код `401` и одно тело
#### Scenario: Запись сверх потолка размера
- **GIVEN** отправитель узнан
- **WHEN** он шлёт запись длиннее потолка размера
- **THEN** ответ имеет код `413`, а тело несёт предел числом
- **AND** ни файла, ни аудиозаписи не заводится
@@ -0,0 +1,111 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос от неузнанного MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`.
Приём стоит тем же адресом, что и список записей, и отличается от него только
методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя
запись он и создаёт.
Ответ MUST нести **список** заведённых записей и место под признак повторного
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом
нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки —
переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним:
меняется форма ответа, не число файлов.
Элемент списка MUST нести те же поля, что и карточка записи, плюс признак
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
Состав карточки нормирует capability `archive`.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес
опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка
публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет
достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе.
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
и код с телом такого отказа нормирует capability `archive` наравне с прочими
ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание —
самый частый отказ у человека на мобильной сети — прежде не был нормирован
ничем и уходил телом ограничителя тела, мимо единой формы.
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, узнанный без учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ
неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно
такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него
нет, и владельцем записи он стать не может.
Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
#### Scenario: Узнанный без учётной записи пользователя
- **GIVEN** предъявлен собственный токен владельца панели
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Пришедший не узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель узнан
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
@@ -0,0 +1,143 @@
## MODIFIED Requirements
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
Правило MUST пускать только владельца файла: незаданное означает «только владелец
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
отдавало чужое аудио тому, кто знает идентификатор записи.
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
токена: отказ наступает там, и требовать его от выдачи значит требовать
механизма, которого нет.
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
хранилища, а не недосмотр.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **AND** забирающий узнан и взял токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Неузнанному файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают неузнанным
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Токен файла выдаётся узнанному по заголовку
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он просит у хранилища токен файла
- **THEN** токен выдаётся
#### Scenario: Конвейер читает файл без узнавания
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** хранилище его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
@@ -0,0 +1,126 @@
## MODIFIED Requirements
### Requirement: Неизвестный путь вне корней открывает приложение
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, —
отдельными адресами стоят `/health` и `/metrics`.
Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и
адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем
же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя
за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается
от любого другого свободного имени, а второй перечень «когда-то занятых корней»
разошёлся бы с первым молча.
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
`/api` не достался бы никому и уехал бы разметкой.
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
контракта остаётся отказом контракта и уходит той формой, которой этот корень
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
приняла бы её за ответ.
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
отвечать `405`.
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
- **GIVEN** приложение открыто на своём маршруте
- **WHEN** браузер спрашивает этот путь заново
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Голый корень разметкой не подменяется
- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без
косой черты
- **THEN** тело ответа — не разметка приложения
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
отделён от него косой чертой
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес входа открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем входа
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** он спрашивает неизвестный путь под корнем приложения
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
- **THEN** тело ответа — не разметка приложения
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
- **THEN** ответ имеет код `404`
- **AND** тело ответа — не разметка приложения
### Requirement: Открытое приложение показывает вошедшего
Приложение SHALL спрашивать сервис, кто пришёл, и показывать его имя. Отказ
`401` MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
только мимо него.
Всякий **иной** отказ и сорванный запрос MUST показываться строкой о неудаче.
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
перехода.
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
адрес почты: его в ответе нет по норме `access`.
#### Scenario: Узнанный виден
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
- **WHEN** он открывает приложение
- **THEN** приложение показывает его имя
#### Scenario: Неузнанному показывают, что его не узнали
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о том, что его не узнали
- **AND** никуда его не уводит
#### Scenario: Отказ сервиса от неузнавания отличается
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
неузнаванием
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о неудаче
- **AND** эта строка не та, которой оно сообщает о неузнавании
@@ -0,0 +1,206 @@
## Критерии приёмки
Дословно из записи задачи `trusted-header-login`. Файл задачи закрытие удалит —
критерии обязаны его пережить.
1. Обращение к адресу приложения с заголовком от доверенного источника идёт от
имени учётной записи, заведённой при первом таком обращении, а повторное с тем
же значением попадает в ту же запись. Оракул — тест обработчика: два запроса
подряд, в хранилище одна запись пользователя.
2. Тот же заголовок с недоверенного адреса даёт `401`, а не вход под названным
именем. Оракул — тест: запрос с адресом источника вне перечня доверенных.
3. Собственные адреса входа хранилища сессии не выдают и учётную запись не
меняют. Оракул — тест по перечню адресов под `/api/collections/users/`: каждый
отвечает отказом.
4. Механики OIDC в дереве не осталось: корня `/auth`, кук входа,
`ApplyProviderSettings`, `cmd/oidcstub` и секрета клиента в конфиге. Оракул —
поиск по этим именам плюс зелёный `task gate`.
5. Разграничение записей по владельцу работает как прежде: чужая запись
неотличима от несуществующей. Оракул — существующие тесты владельца остаются
зелёными.
## Рубрика ревью дизайна
Двенадцать свойств, по которым судится узел этого рода — слой узнавания
предъявителя плюс заведение учётной записи первым обращением. Порождены до
чтения дизайна. Приёмка идёт по одному списку: эти пункты наравне с критериями
постановки выше.
1. Доверие ограничено источником, и источник берётся у соединения, а не у
пересылаемого значения; настройка проверяется на старте, небезопасного
умолчания нет.
2. Заголовок узнаётся однозначно: более одного значения не даёт «первое
попавшееся».
3. Вырожденное значение — пустое, пробельное, сверх предела длины, с
управляющими знаками — не узнаёт никого и не заводит ничего.
4. Ключ учётной записи стабилен, и цена его нестабильности названа в обе стороны
— и смена, и переиспользование. Выборка по ключу имеет свой индекс.
5. Заведение первым обращением идемпотентно и устойчиво к конкуренции; отказ
уникальности не по ключевой колонке имеет назначенный исход.
6. Результат остаётся функцией уже произошедшего: ключевая колонка неизменяема
после заведения.
7. Второго способа стать этим человеком нет, а при двух предъявленных удостоверениях
победитель назначен нормой, а не порядком слоёв.
8. Закрытие поверхности не отменяет законного пути заведения записи самим
сервисом.
9. Значение заголовка — недоверенный вход на всём пути: не в журнал, не в ответ,
не в метку метрики, и в хранилище параметром, а не подстановкой в фильтр.
10. Охват слоя назван, перечень открытых адресов закрыт, посторонний заголовок
ответа открытых адресов не меняет.
11. Срок узнавания назван: всё, что переживает запрос, либо отсутствует, либо
имеет назначенный срок, и этот срок и есть задержка отзыва.
12. Исход узнавания наблюдаем владельцем и неразличим для отправителя.
## 1. Схема хранилища
- [x] 1.1 Новый шаг схемы `202608220001_trusted_header_login.go`: колонка
`provider_login` в коллекции `users` с уникальным индексом, почта
переведена в необязательную, `OAuth2.Enabled = false` и
`OAuth2.Providers = nil`. Применённый `202608120001_oidc_login` не
трогается.
- [x] 1.2 Тот же шаг снимает **все** правила доступа коллекции `users` в пустое —
`ListRule`, `ViewRule`, `CreateRule`, `UpdateRule`, `DeleteRule`. Сегодня
четыре из них библиотечные (`id = @request.auth.id`), и правка своей записи
открыта: это путь захвата чужого имени через ключевую колонку.
- [x] 1.3 Шаг зарегистрирован в `migrations.go`, имя ключевой колонки объявлено
константой рядом с именами коллекций.
- [x] 1.4 `schema_test.go` сторожит `users` наравне с шестью коллекциями,
которые он уже проверяет: все пять правил пусты.
- [x] 1.5 Откат шага возвращает то, что было до него, кроме открытого создания
записи и открытой правки, и не падает на проверке коллекции.
- [x] 1.6 `docs/database.md` описывает новую колонку, снятые настройки и снятые
правила доступа.
## 2. Настройка
- [x] 2.1 Секция входа конфига переписана: адреса провайдера, идентификатор и
секрет клиента, адрес возврата и признак защищённой куки убраны, перечень
доверенных адресов заведён: секция `[auth]` остаётся под своим именем, в
ней один ключ `trusted_proxies` (решение человека 2026-08-22).
- [x] 2.2 Проверка настройки на старте: пустой перечень и нечитаемая строка
роняют старт с именем ключа.
- [x] 2.3 `config.example.toml` переписан вместе с секцией, раздел про локальный
вход заменён на подставной прокси.
## 3. Узнавание пришедшего
- [x] 3.1 Слой узнавания в транспорте HTTP: читает заголовок, судит адрес пира по
перечню, ставит учётную запись предъявителя только когда её ещё нет.
Вешается корневым, но действует на объявленной области — корень приложения
плюс адрес выдачи файлового токена; область выводится из перечня адресного
пространства, а не пишется вторым списком.
- [x] 3.2 Приём значения заголовка: пустое и пробельное не узнают никого, более
одного значения не узнаёт никого, предел длины и отказ на управляющие
знаки, сравнение точное. Значение уходит в хранилище параметром, а не
подстановкой в текст фильтра.
- [x] 3.3 Поиск и заведение учётной записи — **методом пакета хранилища**, а не
куском в транспорте: у правила один дом, и `api-tokens` возьмёт его же.
Имя и почта берутся только при заведении, найденная запись не
переписывается; отказ уникальности по ключевой колонке ведёт к повторному
поиску, по любой другой — к заведению записи без почты.
- [x] 3.4 Отказ хранилища при узнавании кончается отказом сервиса, а не
молчаливым проходом неузнанным.
- [x] 3.5 Журнал: исходы «заголовок с недоверенного адреса» и «учётная запись
заведена» — с адресом пира и идентификатором записи, без значения
заголовка; перечень доверенных адресов называется строкой при подъёме.
- [x] 3.6 `ApplyProviderSettings`, `ProviderSettings` и `ProviderName` убраны из
пакета хранилища; `SessionDuration` убран вместе со сроком сессии.
## 4. Снос механики OIDC
- [x] 4.1 `internal/controller/http/auth.go` удалён целиком вместе с корнем
`/auth` в перечне адресного пространства.
- [x] 4.2 `SessionFromCookie` и `BlockSessionRefresh` удалены; места их привязки
переписаны на новый слой.
- [x] 4.3 `cmd/oidcstub` удалён.
- [x] 4.4 Приложение: адрес, которым экран уводил ко входу, убран; неузнавание
показывается строкой и отличается от прочей неудачи. Юнит-тесты приложения
обновлены.
## 5. Способ представиться без прокси
- [x] 5.1 Заведён `cmd/devtools` с подкомандой `proxy`: слушает свой порт,
ставит заголовок, переправляет запрос сервису. Подкоманду `admin` заводит
задача `dev-run-task`. В образ пакет не едет: строка сборки `Dockerfile`
называет точку входа поимённо.
- [x] 5.2 `CLAUDE.md`, раздел «Команды» и раздел «Запреты» — про локальный вход
без прокси; `README.md`, если он про это говорит.
## 6. Проверки
- [x] 6.1 Тест: первое обращение с доверенного адреса заводит учётную запись,
второе с тем же значением попадает в ту же — в хранилище одна запись
(критерий 1).
- [x] 6.2 Тест: тот же заголовок с недоверенного адреса даёт `401`, и учётной
записи не появляется (критерий 2).
- [x] 6.3 Тест по перечню собственных адресов входа хранилища под
`/api/collections/users/`: каждый отвечает отказом и учётной записи не
меняет (критерий 3).
- [x] 6.4 Тест: годный собственный токен побеждает заголовок, а протухший
узнаванию не мешает.
- [x] 6.4a Тест: узнанный не правит свою запись в коллекции пользователей
запросом к хранилищу и не перечисляет коллекцию — путь захвата чужого имени
закрыт.
- [x] 6.4b Тест: пустой заголовок, два значения одного заголовка и значение
сверх предела длины не узнают никого и записи не заводят.
- [x] 6.4c Тест: два одновременных первых обращения одним значением дают одну
запись.
- [x] 6.4d Тест: занятый адрес почты не мешает завести запись — она заводится
без почты.
- [x] 6.4e Тест: запрос с заголовком на `GET /health` учётной записи не заводит.
- [x] 6.4f Тест: прежние адреса под корнем `/auth` отдают разметку приложения.
- [x] 6.5 Тест: значение заголовка не попадает в журнал.
- [x] 6.6 Тест: ответ на успешный запрос не ставит браузеру куки.
- [x] 6.7 Тест: токен файла выдаётся узнанному по заголовку, и путь к файлу
записи проходит целиком.
- [x] 6.8 Существующие тесты владельца и разграничения переписаны на новый способ
представиться и остаются зелёными (критерий 5).
- [x] 6.9 Тесты входа, обмена кода, куки, продления и выхода удалены вместе с
предметом.
## 7. Документы и закрытие
- [x] 7.1 `docs/security.md`: периметр, недоверенный вход, разграничение доступа;
четвёртый сдвиг про секрет в базе снят; требование к контуру — прокси
**ставит** заголовок, а не пропускает пришедший — названо поимённо; там же
цена переиспользования логина у провайдера.
- [x] 7.2 `CLAUDE.md`: изъятие из инварианта о секрете снято.
- [x] 7.3 `docs/architecture.md` приведён в соответствие; в «Единые точки
проекта» добавлена строка про дом правила узнавания предъявителя.
- [x] 7.3a `docs/passport.md`: строки про сессию OIDC у потребителей и в границе
«Управление учётными записями» — учётную запись сервис не заводит по своей
воле, а зеркалит имя, названное провайдером.
- [x] 7.4 Поиск по `oidc`, `transcriber_session`, `transcriber_login`,
`ApplyProviderSettings`, `/auth/` в дереве не находит живого кода
(критерий 4).
- [x] 7.5 `task gate` зелёный, кроме унаследованного `hadolint DL3066` — отказ
воспроизводится на чистом `origin/master`, объявлен долгом в `CLAUDE.md`
(критерий 4).
## 8. Отработка ревью кода
- [x] 8.1 Слой узнавания переехал за ограничитель частоты: отвергнутый запрос
больше не заводит учётной записи.
- [x] 8.2 Заведение учётной записи пишется в журнал; два значения заголовка —
предупреждением, а не отладочным уровнем.
- [x] 8.3 Имя и почта принимаются, а не кладутся как есть: длинное имя больше не
запирает человека вечным отказом сервиса.
- [x] 8.4 Отказ уникальности судится по коду, а не по имени колонки: негодная
почта отличима от занятой.
- [x] 8.5 Предел логина считается в знаках, а не в байтах.
- [x] 8.6 Уникальный индекс по ключу сделан частичным — как соседний индекс
почты; подъём на непустой базе больше не роняет накатку.
- [x] 8.7 Ключ учётной записи закрыт от правки рукой в панели модельным хуком.
- [x] 8.8 Хранилищу назван заголовок адреса спрашивающего: бюджет ограничителя
перестал быть общим на весь сервис.
- [x] 8.9 Приложение показывает текст сервера, своего словаря под коды ответа не
заводит; текст `401` больше не зовёт ко входу, которого нет.
- [x] 8.10 Оснастка берёт имена заголовков константами транспорта.
- [x] 8.11 Три записи конвенций приведены к действительности; словарь полей
журнала пополнен, изъятие про вывод оснастки записано.
- [x] 8.12 Порог отката образа назван в «Эксплуатации»; требование к контуру про
`X-Forwarded-For` и цена ширины перечня — в модели угроз.
- [x] 8.13 Образец конфига сужен до адреса прокси.
- [x] 7.6 Поведенческая проверка: сервис поднят локально, приложение открывается,
учётная запись заводится первым обращением, запрос без заголовка получает
отказ.
+477 -321
View File
@@ -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** журнал подъёма называет доверенные адреса
+4 -4
View File
@@ -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** ни файла, ни аудиозаписи не заводится
+17 -17
View File
@@ -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`: собственного порога по размеру у приёма нет
+23 -15
View File
@@ -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: Свой файл отдаётся
+44 -51
View File
@@ -8,9 +8,7 @@
Спека отвечает за **сервис**, а не за сборщик: правило неизвестного пути, срок
хранения ответов, поведение при несобранном приложении и то, что уходит в журнал.
Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения.
## Requirements
### Requirement: Приложение отдаётся самим бинарником
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника.
@@ -71,8 +69,15 @@
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа,
`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`.
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, —
отдельными адресами стоят `/health` и `/metrics`.
Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и
адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем
же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя
за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается
от любого другого свободного имени, а второй перечень «когда-то занятых корней»
разошёлся бы с первым молча.
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
@@ -114,16 +119,23 @@
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес входа открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем входа
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
- **GIVEN** человек вошёл и предъявил сессию
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** он спрашивает неизвестный путь под корнем приложения
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
@@ -138,22 +150,6 @@
- **THEN** ответ имеет код `404`
- **AND** тело ответа — не разметка приложения
#### Scenario: Наблюдение приложением не подменяется
- **WHEN** запрос приходит на пробу здоровья
- **THEN** отвечает проба здоровья, а не приложение
#### Scenario: Чужой метод отвечает отказом
- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым
страницу не открывают
- **THEN** ответ имеет код `405`
#### Scenario: Проверка доступности разметку получает
- **WHEN** разметку спрашивают методом `HEAD`
- **THEN** ответ имеет код `200`
### Requirement: Обновлённое приложение доходит до браузера
Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим
@@ -239,44 +235,41 @@
### Requirement: Открытое приложение показывает вошедшего
Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ
`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком
адресе данных, и это его штатное состояние, а не поломка.
Приложение SHALL спрашивать сервис, кто пришёл, и показывать его имя. Отказ
`401` MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
только мимо него.
Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться
строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг:
приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе
сервиса или ограничителя частоты круг пошёл бы заново.
Всякий **иной** отказ и сорванный запрос MUST показываться строкой о неудаче.
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
перехода.
Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна
скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в
ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен,
и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме
`access`.
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
адрес почты: его в ответе нет по норме `access`.
#### Scenario: Вошедший виден
#### Scenario: Узнанный виден
- **GIVEN** человек вошёл и получил куку сессии
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
- **WHEN** он открывает приложение
- **THEN** приложение показывает его имя
#### Scenario: Не вошедшему предлагается вход
#### Scenario: Неузнанному показывают, что его не узнали
- **GIVEN** сессии у человека нет
- **WHEN** он открывает приложение
- **THEN** приложение ведёт его ко входу
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о том, что его не узнали
- **AND** никуда его не уводит
#### Scenario: Отказ сервиса ко входу не уводит
#### Scenario: Отказ сервиса от неузнавания отличается
- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является
отсутствием сессии
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
неузнаванием
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о неудаче
- **AND** ко входу оно не уводит
- **AND** эта строка не та, которой оно сообщает о неузнавании
#### Scenario: Учётная запись без имени
- **GIVEN** человек вошёл, а имени у его учётной записи нет
- **WHEN** он открывает приложение
- **THEN** приложение показывает, что вход выполнен
- **AND** адреса почты на экране нет
+15 -10
View File
@@ -17,14 +17,15 @@ interface ErrorBody {
}
/**
* Исход запроса. Отсутствие сессии свой исход, а не разновидность неудачи:
* экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт,
* иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение
* вход приложение».
* Исход запроса. «Сервис вас не узнал» свой исход, а не разновидность
* неудачи: человек по нему делает не то, что по «сервис не отвечает». Прежде
* этот исход уводил ко входу; уводить стало некуда своего входа у сервиса
* нет, а заголовок ставит обратный прокси, и различие сохраняется ради
* текста, а не ради перехода.
*/
export type ApiResult<T> =
| { status: 'ok'; value: T }
| { status: 'unauthorized' }
| { status: 'unrecognized'; message: string }
| { status: 'failed'; message: string }
const noConnectionMessage = 'Связи нет'
@@ -54,7 +55,11 @@ async function request<T>(path: string): Promise<ApiResult<T>> {
}
if (response.status === 401) {
return { status: 'unauthorized' }
// Текст берётся у сервера, а не сочиняется здесь: единая форма отказа —
// обязанность API, и второй словарь на клиенте разошёлся бы с первым.
// Свой исход у этого кода всё равно есть: «сервис вас не узнал» и «сервис
// не отвечает» ведут человека к разному.
return { status: 'unrecognized', message: await failureMessage(response) }
}
if (!response.ok) {
@@ -68,10 +73,10 @@ async function request<T>(path: string): Promise<ApiResult<T>> {
}
}
/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */
/**
* Спрашивает сервис, кто пришёл. Заголовок ставит обратный прокси, и приложение
* его не видит вовсе этот адрес единственный способ узнать вошедшего.
*/
export function fetchMe(): Promise<ApiResult<Me>> {
return request<Me>('/me')
}
/** Адрес, которым сервис уводит человека ко входу. */
export const loginPath = '/auth/login'
+38 -7
View File
@@ -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,
+12 -6
View File
@@ -1,19 +1,23 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchMe, loginPath } from '../api'
import { fetchMe } from '../api'
// Состояние экрана живёт в экране: общего между экранами пока нет.
const state = ref<'loading' | 'signed-in' | 'failed'>('loading')
const state = ref<'loading' | 'signed-in' | 'unrecognized' | 'failed'>('loading')
const name = ref('')
const failure = ref('')
const unrecognized = ref('')
async function load() {
const result = await fetchMe()
if (result.status === 'unauthorized') {
// Единственная ветка, уводящая ко входу. Прочие отказы ведут сюда же
// только через круг «приложение вход приложение», поэтому их здесь нет.
window.location.assign(loginPath)
if (result.status === 'unrecognized') {
// Уводить некуда: своего входа у сервиса нет, а имя пришедшего ставит
// обратный прокси. Показываем строкой той, что прислал сервер, и
// отдельной от прочей неудачи: «вас не узнали» и «сервис не отвечает»
// ведут человека к разному.
unrecognized.value = result.message
state.value = 'unrecognized'
return
}
@@ -36,6 +40,8 @@ onMounted(load)
<p v-if="state === 'loading'">Загружается</p>
<p v-else-if="state === 'unrecognized'">{{ unrecognized }}</p>
<p v-else-if="state === 'failed'">{{ failure }}</p>
<!-- Имени у учётной записи может не быть вовсе: тогда говорим, что вход