документы: сведены расхождения, найденные сверкой канона

- один факт — один дом: рецепт локального входа, правило чтения
  X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к
  своим домам, копии заменены ссылками
- форма [auth.test_headers] выровнена по образцу конфига в семи местах;
  сценарии intake и archive перестали ссылаться на сессию, которой сервис
  не выдаёт
- поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку
  адреса пира зовут трое, а не двое; обзор capability access знает о
  задаче 2026-08-23
This commit is contained in:
av
2026-08-23 16:41:06 +03:00
parent 11269c1567
commit 31ce520c5b
12 changed files with 65 additions and 66 deletions
+3 -5
View File
@@ -278,11 +278,9 @@ Node на машину **не ставится**: шаг сборки прило
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
процессом: рецепт из трёх правок записан связным блоком в
`config.example.toml`, под перечнем доверенных адресов. Коротко: пара петлевых
адресов в перечень, `[server] debug = true`, раскомментированная секция
`[auth.test_headers]` с ключом `Remote-User`. Приложение при этом открывают по
адресу сервиса, второго порта нет. Заполненная имитация при выключенном
предохранителе роняет старт с именем ключа. Ключей боевого
`config.example.toml`, под перечнем доверенных адресов. Приложение при этом
открывают по адресу сервиса, второго порта нет. Заполненная имитация при
выключенном предохранителе роняет старт с именем ключа. Ключей боевого
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
+3 -8
View File
@@ -52,15 +52,10 @@
сервис по своим настройкам. Второго процесса для этого не нужно: приложение
открывают по адресу сервиса.
Рецепт — три правки `config.toml` сверху вниз:
Рецепт целиком — связным блоком в `config.example.toml`, под перечнем доверенных
адресов: там названы три правки, принимаемые имена заголовков и цена включения.
1. добавить в перечень доверенных адресов пару петлевых — `127.0.0.1` и `::1`;
2. поставить в секции `[server]` ключ `debug = true`;
3. раскомментировать секцию `[auth.test_headers]` и назвать в ней `Remote-User`.
Тот же рецепт записан связным блоком в `config.example.toml`, под перечнем
доверенных адресов, — там же названы принимаемые имена заголовков и цена
включения. Заполненная секция имитации при выключенном предохранителе роняет
Заполненная секция имитации при выключенном предохранителе роняет
старт с именем ключа: сервис с включённым предохранителем называет пришедшего
сам, никого не спросив, и в бою этот ключ стоит `false`.
@@ -1,7 +1,7 @@
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
- **Дата:** 2026-08-22
- **Источник:** openspec/changes/archive/trusted-header-login/design.md
- **Источник:** [../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md](../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md), разделы Р1 и Р3
## Решение
+17 -6
View File
@@ -59,7 +59,13 @@
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
записи не бывает вовсе — колонка владельца пустого значения не принимает.
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. Здесь же
изъятие отладочного запуска: при включённом предохранителе `[server] debug`
заголовки входа подставляет сам сервис значениями из `[auth.test_headers]`с
отказами старта, строкой журнала и закрытым перечнем следствий ключа. Задача
`config-test-headers-login` 2026-08-23; решения —
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md)
и [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
Задача, которая его трогает, дописывает спеку своей capability.
@@ -84,7 +90,7 @@
- **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему —
код или оснастка; в боевом бинарнике он появляется только отдельным решением
владельца и только под ключом, названным своим предметом: имитацию заголовков
входа объявляет секция `[auth] test_headers`, подмену распознавания — правка
входа объявляет секция `[auth.test_headers]`, подмену распознавания — правка
кода (`internal/adapter/recognizer/memory.go`). Ключ, названный общим словом,
обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт
себя как в бою». Предохранитель `[server] debug` вторым именем собеседнику при
@@ -161,7 +167,7 @@
| Компонент | Где | Что делает |
| --- | --- | --- |
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Подстановка — звено необязательное: при выключенном предохранителе `[server] debug` и при пустой имитации она в цепочку не встаёт вовсе |
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Условия, при которых звено подстановки встаёт в цепочку, нормирует [access](../openspec/specs/access/spec.md), «Отладочный запуск называет пришедшего настройками» |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
@@ -184,7 +190,8 @@
## Внешние границы и форматы
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
`UsePathStyle`. Ключ объекта — имя файла записи, то есть её идентификатор с
расширением; идентификаторы строит `internal/ident` и они ULID, а не UUID.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
@@ -192,6 +199,10 @@
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
нужен. База и файлы записей лежат под одним каталогом данных.
- **`github.com/BurntSushi/toml`.** Формат единственного источника настроек.
Негодный TOML останавливает старт; незнакомый ключ разбор не судит и молча
отбрасывает — наблюдение и чем оно проверено, в
[research/toml-unknown-keys.md](research/toml-unknown-keys.md).
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
@@ -284,8 +295,8 @@
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository``internal/controller/http.TrustedHeaderIdentity` |
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth] test_headers`: ключ, не совпавший ни с одним, роняет старт |
| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go``peerAddress` и `isTrusted`. Зовут их двое: узнавание и подстановка заголовков отладочного запуска. Второй сверщик разошёлся бы с первым молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth.test_headers]`; что делает старт с ключом вне набора, нормирует [access](../openspec/specs/access/spec.md), «Настройка, открывающая вход всем, роняет старт» |
| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go``peerAddress` и `isTrusted`. Зовут их узнавание, подстановка заголовков отладочного запуска и ограничитель частоты. Свой сверщик разошёлся бы с общим молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
+4 -6
View File
@@ -63,12 +63,10 @@ force_shutdown_timeout = <N> # ждать остановки ворке
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
входом.
Там же, комментарием под секцией, стоит **рецепт локального входа одним связным
блоком**: три правки сверху вниз — пара петлевых адресов в перечень доверенных,
`[server] debug = true`, раскомментированная секция `[auth.test_headers]` с
ключом `Remote-User`. Блок один, а не три комментария по месту: правки связаны
между собой, и применённая порознь любая из них роняет старт либо оставляет
сервис никого не узнающим. Рабочей строкой в образце стоит боевое значение —
Там же, комментарием под секцией, стоит рецепт локального входа **одним связным
блоком**, а не тремя комментариями по месту: правки связаны между собой, и
применённая порознь любая из них роняет старт либо оставляет сервис никого не
узнающим. Рабочей строкой в образце стоит боевое значение —
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
+4 -4
View File
@@ -87,10 +87,10 @@ stdlib-логом в поток ошибок. Это выбор, а не дол
*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
*Изъятие:* строка о подставленных заголовках входа
(`internal/controller/http/substitute.go`) адресована разработчику, а идёт на
`INFO`: `DEBUG` включить нечем, а в бою она не пишется вовсе — подстановку
держит выключенный умолчанием предохранитель `[server] debug`.
*Изъятие:* строка о подставленных заголовках входа адресована разработчику, а
идёт на `INFO` — уровень и его довод нормирует спека
[access](../../openspec/specs/access/spec.md), «Отладочный запуск виден в
журнале».
## Время
+2 -3
View File
@@ -424,9 +424,8 @@ Crockford. Выдаёт их приложение единой точкой `int
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
ограничивает. Сама цепочка читается справа налево с отбрасыванием доверенных
адресов, поэтому дописывающий прокси правилом покрыт; почему так —
[security.md](security.md), «Периметр».
ограничивает. Как читается цепочка — спека
[archive](../openspec/specs/archive/spec.md); здесь только числа бюджета.
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
+1 -1
View File
@@ -345,7 +345,7 @@ API и имя не откатываются обратной правкой по
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
секции `[auth] test_headers` под предохранителем `[server] debug` — прежде
секции `[auth.test_headers]` под предохранителем `[server] debug` — прежде
`cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
заведение учётной записи первым обращением, отказ с недоверенного адреса и
отказ старта на пустом перечне.
+12 -13
View File
@@ -85,10 +85,12 @@ Telegram — связи чата с учётной записью сервис
**Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23**
задачей `config-test-headers-login`. При включённом предохранителе
`[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из
секции `[auth] test_headers`: на машине разработчика прокси нет, а браузер
секции `[auth.test_headers]`: на машине разработчика прокси нет, а браузер
заголовков не ставит. Узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует
изъятие спека [access](../openspec/specs/access/spec.md).
изъятие спека [access](../openspec/specs/access/spec.md), решение о подстановке
самим сервисом —
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md).
Держится оно тремя вещами, и других нет: умолчание предохранителя —
«выключено»; заполненная имитация при выключенном предохранителе роняет старт с
@@ -103,19 +105,16 @@ Telegram — связи чата с учётной записью сервис
перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл
через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного
предохранителя у изъятия нет: требование петлевого перечня рассматривалось и
снято решением владельца на чекпоинте задачи.
снято — [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка:
иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и
бюджет становится общим на весь сервис. Цепочка читается **справа налево**,
доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым
значением распоряжается сам спрашивающий, а правое приписал ближайший к нам
прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит
несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом
покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в
отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто
пришёл, решает адрес самого соединения.
Как именно читается цепочка, нормирует спека
[archive](../openspec/specs/archive/spec.md), «Адреса приложения живут своим
пространством». Отсюда периметровое следствие: прокси, дописывающий
`X-Forwarded-For` к присланному, этим правилом покрыт, и требования
«перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`.
Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого
соединения.
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
+14 -15
View File
@@ -81,12 +81,10 @@
«Предохранитель отладки включает только подстановку заголовков»; снимать их
поодиночке нельзя: барьер держится всеми разом.
Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию
выключен, а заполненная имитация без него роняет старт. Боевой перечень
доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с
включённым предохранителем и заполненной имитацией, назовёт своим именем всякого,
чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным
путём.
Держится изъятие умолчанием предохранителя «выключено» и отказом старта при
заполненной имитации без него. Что остаётся между боевой выкладкой и открытым
входом целиком, включая опору вне репозитория, перечисляет модель угроз —
`docs/security.md`, «Периметр».
#### Scenario: Названный провайдером получает доступ
@@ -275,14 +273,15 @@ MUST отвечать отказом `401`, когда пришедший не
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` заголовок входа ставит не прокси, а сам сервис значением из
секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного
секции `[auth.test_headers]`; узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает. Условия, при которых источник этот законен,
и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск
называет пришедшего настройками», «Настройка, открывающая вход всем, роняет
старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает
только подстановку заголовков». Держится изъятие умолчанием предохранителя
«выключено» и отказом старта при заполненной имитации без него; боевой перечень
доверенных адресов включению предохранителя не мешает.
и проверки старта, которыми он держится, перечислены требованием «Кого пускать,
решает провайдер».
Держится изъятие умолчанием предохранителя «выключено» и отказом старта при
заполненной имитации без него. Что остаётся между боевой выкладкой и открытым
входом целиком, включая опору вне репозитория, перечисляет модель угроз —
`docs/security.md`, «Периметр».
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
@@ -591,7 +590,7 @@ MUST не выдавать вовсе — ни куки, ни токена се
### Requirement: Отладочный запуск называет пришедшего настройками
Сервис SHALL подставлять запросу заголовки входа значениями из секции
`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST
`[auth.test_headers]`, когда включён предохранитель `[server] debug`, и MUST
делать это так, чтобы узнавание не отличало подставленный заголовок от
поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана
быть той же, что работает в бою: отладке подлежит боевой путь, а не его
@@ -685,7 +684,7 @@ MUST не выдавать вовсе — ни куки, ни токена се
Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server]
debug` MUST читаться как выключенный предохранитель, а отсутствие секции
`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый
`[auth.test_headers]` — как пустая секция: конфиг сегодняшнего дня, не тронутый
ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST
кончаться отказом старта, а не прочтением «включено».
+1 -1
View File
@@ -265,7 +265,7 @@ MUST называть в нём потолок размера одной зап
#### Scenario: Потолок размера равен тому, которым сервис отвергает
- **GIVEN** человек вошёл и предъявил сессию
- **GIVEN** человек узнан
- **WHEN** он спрашивает пределы сервиса
- **THEN** потолок размера в ответе равен потолку, которым сервис ограничивает
тело запроса приёма
+3 -3
View File
@@ -302,19 +302,19 @@ MUST ограничивать его длину и MUST убирать из не
#### Scenario: Имя доходит до записи
- **GIVEN** отправитель предъявил сессию
- **GIVEN** отправитель узнан
- **WHEN** он шлёт запись с именем `разговор.mp3`
- **THEN** колонка имени файла у заведённой записи несёт `разговор.mp3`
#### Scenario: Заголовок принятой записи пуст
- **GIVEN** отправитель предъявил сессию
- **GIVEN** отправитель узнан
- **WHEN** он шлёт запись с именем `разговор.mp3`
- **THEN** колонка заголовка у заведённой записи пуста
#### Scenario: Длинное и грязное имя приходит обрезанным и очищенным
- **GIVEN** отправитель предъявил сессию
- **GIVEN** отправитель узнан
- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки
- **THEN** колонка имени файла несёт имя не длиннее предела
- **AND** управляющих знаков в нём нет