вход переехал на доверенный заголовок 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
@@ -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` поимённо), а
слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял
её у себя в чужой сети, отвечает за это сам.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне