вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
|
||||
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
пункт 6
|
||||
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
|
||||
|
||||
- **Дата:** 2026-08-22
|
||||
- **Источник:** openspec/changes/archive/trusted-header-login/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Сервис перестаёт вести вход сам. Кто пришёл, он узнаёт из заголовка
|
||||
`Remote-User`, поставленного обратным прокси, который сходил к Authelia;
|
||||
заголовку верят только с адреса из объявленного перечня, а адрес берётся у
|
||||
самого соединения. Учётная запись заводится первым обращением с новым логином и
|
||||
находится по нему же дальше.
|
||||
|
||||
Убраны целиком: корень `/auth` с тремя адресами, куки `transcriber_session` и
|
||||
`transcriber_login`, сверка состояния и проверочный код PKCE, обмен кода
|
||||
внутрипроцессным запросом к роутеру хранилища, слои предъявления куки и запрета
|
||||
продления, приведение настроек провайдера к конфигу, секрет клиента и срок жизни
|
||||
сессии.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата из источника, раздел Р3:
|
||||
|
||||
> Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
|
||||
> заголовку, который прокси поставил, сходив к Authelia.
|
||||
>
|
||||
> Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
|
||||
> значение, живущее семь суток, отозванный у провайдера человек работал до
|
||||
> истечения этого значения, и другого канала отзыва не было.
|
||||
|
||||
Оттуда же, Р1 — почему доверие судится адресом соединения, а не пересылаемым
|
||||
заголовком:
|
||||
|
||||
> **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
|
||||
> запрос. Барьер, который подделывается той же строкой, что и обходится, не
|
||||
> барьер вовсе.
|
||||
|
||||
Отвергнут промежуточный вариант — заголовок как вход, сессия хранилища как
|
||||
продолжение (Р3):
|
||||
|
||||
> Дешевле в работе (слой срабатывал бы раз в неделю, а не на каждом запросе), но
|
||||
> возвращает ровно то, что задача убирает: значение, переживающее отзыв. Семь
|
||||
> суток вернулись бы вместе с ним.
|
||||
|
||||
Контур к решению был готов заранее: обратный прокси уже отдавал `Remote-*` трём
|
||||
соседним сервисам того же контура, а правила для этого сервиса там не было
|
||||
вовсе — он не выложен.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Отзыв доступа действует со следующего запроса, а не через семь суток:
|
||||
Authelia судит каждое обращение.
|
||||
- `+` Секрет клиента исчез из конфига и из базы. Изъятие из инварианта «Секрет не
|
||||
покидает конфиг» снято: чтение файла базы больше не равносильно чтению
|
||||
секрета.
|
||||
- `+` Своего протокола входа у сервиса не осталось — вместе с ним исчезли пять
|
||||
накопившихся задач о его механике.
|
||||
- `+` Панель закрывается доменом, а не правилом на литерал пути; обход подменой
|
||||
знака перестаёт существовать.
|
||||
- `−` **Весь барьер держится на настройке прокси.** Прокси, добавляющий заголовок
|
||||
вместо замены, открывает сервис любому под любым именем. Половину беды сервис
|
||||
закрывает сам — запрос с двумя значениями заголовка не узнаёт никого, — вторую
|
||||
проверить отсюда нечем: правило живёт в `pet-project-server`.
|
||||
- `−` **Логин у провайдера переиспользуем**, и новый его владелец получает архив
|
||||
прежнего. Неизменяемого признака заголовок не приносит; не допускать
|
||||
переиспользования — работа провайдера. Обратная сторона: переименование
|
||||
заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни
|
||||
убрать.
|
||||
- `−` Поиск учётной записи идёт на каждом запросе к области приложения вместо
|
||||
раза в неделю. Уникальный индекс делает это одним обращением к базе; замера не
|
||||
требовалось — сервисом пользуются единицы человек.
|
||||
- `−` Половина работы лежит вне репозитория: до того как правило прокси и правило
|
||||
Authelia на домен заведут, сервис не узнает никого.
|
||||
+3
-2
@@ -35,6 +35,7 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
|
||||
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
|
||||
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
|
||||
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
|
||||
@@ -49,9 +50,9 @@
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
|
||||
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
|
||||
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
|
||||
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
|
||||
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
|
||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
|
||||
|
||||
+38
-15
@@ -15,7 +15,7 @@
|
||||
[conventions/go-linters.md](conventions/go-linters.md).
|
||||
|
||||
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
|
||||
входов**: приём за сессией, имя отправителя не доходит ни до
|
||||
входов**: приём только от узнанного, имя отправителя не доходит ни до
|
||||
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
|
||||
наблюдатель видит единственный поднятый вход. Задачи
|
||||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||
@@ -51,9 +51,10 @@
|
||||
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
|
||||
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
|
||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
|
||||
его дальше: узнавание по заголовку доверенного источника, заведение учётной
|
||||
записи первым обращением и то, какие адреса остаются открытыми. Собственный
|
||||
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
|
||||
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
|
||||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
|
||||
записи не бывает вовсе — колонка владельца пустого значения не принимает.
|
||||
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
|
||||
@@ -150,6 +151,18 @@
|
||||
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
|
||||
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
|
||||
выкладки `record-centric-model` откат образа работает, после — нет.
|
||||
- **Откат образа через шаг схемы `202608220001` обрывает вход.** Шаг закрывает
|
||||
правила коллекции пользователей наглухо, а прежний образ заводил учётную
|
||||
запись внутренним запросом обмена кода — и этот запрос закрытое правило
|
||||
отвергает. Проверено прогоном прежнего кода поверх нового каталога данных:
|
||||
вход отвечает `401`, в журнале «storage rejected the exchange with code 403».
|
||||
Порог тот же по форме, что и у `202608140002`: до выкладки
|
||||
`trusted-header-login` откат работает, после — нет, и лечится он повторной
|
||||
выкладкой вперёд. Обратного шага схемы нет и не планируется.
|
||||
|
||||
Окно этого порога сегодня пусто: сервис не выложен, а откат уже не работает с
|
||||
шага `202608140002`. Строка стоит здесь потому, что порог принято называть
|
||||
прямо, а не потому, что риск сегодня чем-то грозит.
|
||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||
@@ -199,7 +212,8 @@
|
||||
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
|
||||
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
||||
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||||
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути и уровень журнала |
|
||||
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
|
||||
| Узнавание предъявителя | `pbrepo.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод — `internal/controller/http.TrustedHeaderIdentity` |
|
||||
|
||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
|
||||
@@ -217,11 +231,20 @@
|
||||
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
|
||||
собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
|
||||
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
|
||||
работает под непривилегированным пользователем `transcriber`.
|
||||
работает под непривилегированным пользователем `transcriber` — в образе он
|
||||
назван числом, `USER 1000:1000`, а не именем: имя разрешает в идентификатор сам
|
||||
образ, и хост, которому надо понять владельца файлов в смонтированном каталоге,
|
||||
разрешить его не может. Числа те же, что при заведении пользователя.
|
||||
|
||||
Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь
|
||||
пакет: рядом в `cmd/` живёт `oidcstub`, подставной провайдер OIDC для локального
|
||||
входа, и в образе ему делать нечего.
|
||||
пакет: рядом в `cmd/` живёт `devtools`, оснастка разработчика, и в образе ей
|
||||
делать нечего.
|
||||
|
||||
Оснастка лежит **одним** пакетом с подкомандами, а не пакетом на инструмент, и
|
||||
это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки
|
||||
здесь, «Деплой» в этом файле, «Команды» в памятке, `README`, — и забытая строка
|
||||
сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||
четыре места однажды, сколько бы подкоманд в нём ни завелось.
|
||||
|
||||
Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес:
|
||||
у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому
|
||||
@@ -237,13 +260,13 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер —
|
||||
Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
|
||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
|
||||
живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
|
||||
[access](../openspec/specs/access/spec.md), решения —
|
||||
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
|
||||
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
|
||||
- **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
|
||||
обратный прокси, сходив к Authelia; учётная запись заводится первым обращением
|
||||
и находится по логину у провайдера. Задача `trusted-header-login` 2026-08-22.
|
||||
Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа
|
||||
судит провайдер на каждом запросе, а не однажды выданное значение. Норма —
|
||||
[access](../openspec/specs/access/spec.md), решение —
|
||||
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||||
зависит возвращение убранного входа.
|
||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
|
||||
+17
-14
@@ -57,16 +57,17 @@ force_shutdown_timeout = <N> # ждать остановки ворке
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||
|
||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
|
||||
говорит, какой формы значение здесь ждут. Пустым оставлен только
|
||||
`client_secret` — он и есть секрет.
|
||||
*Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
|
||||
примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
|
||||
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
|
||||
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
|
||||
входом.
|
||||
|
||||
Там же, комментарием под секцией, стоит **второй набор значений `[auth]` — под
|
||||
подставной провайдер `cmd/oidcstub`**. Они — не живая форма, и это намеренно:
|
||||
образец описывает боевую выкладку, а локальный вход — способ до неё дойти, и два
|
||||
рабочих набора в одном файле читались бы как выбор без указания, какой из них
|
||||
чей.
|
||||
Там же, комментарием под секцией, стоит **второе значение перечня — петлевой
|
||||
адрес, под подставной прокси `cmd/devtools proxy`**. Оно стоит закомментированным, и это
|
||||
намеренно: образец описывает боевую выкладку, а локальный вход — способ до неё
|
||||
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
|
||||
какое из них чьё.
|
||||
|
||||
## Поля по дискриминатору `type`
|
||||
|
||||
@@ -95,7 +96,8 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
|
||||
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
|
||||
`yandex.object_storage_access_key_id`,
|
||||
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||
`yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
|
||||
2026-08-22 вместе с собственным входом.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
владелец — пользователь процесса (`1000:1000`).
|
||||
- В `config.example.toml` секретные поля — пустые строки.
|
||||
@@ -137,10 +139,11 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет
|
||||
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
|
||||
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
|
||||
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
|
||||
`client_secret` в журнал попасть не должно.
|
||||
процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с
|
||||
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
|
||||
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
|
||||
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
|
||||
силе для прочих секций, где секреты есть.
|
||||
|
||||
## Структура в коде
|
||||
|
||||
|
||||
@@ -31,10 +31,13 @@ OpenSpec.
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
|
||||
```
|
||||
|
||||
*Расхождение:* текстовый обработчик ставят **оба** пакета `cmd/` —
|
||||
`slog.NewTextHandler(os.Stdout, …)` и в `transcriber`, и в `oidcstub`. У второго
|
||||
это выбор, а не долг: его вывод читает человек в терминале, и разбирать его
|
||||
отбором никто не станет.
|
||||
*Расхождение:* текстовый обработчик ставит `cmd/transcriber` —
|
||||
`slog.NewTextHandler(os.Stdout, …)`.
|
||||
|
||||
*Изъятие:* оснастка разработчика `cmd/devtools` печатает не через `slog`, а
|
||||
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
|
||||
терминале, в сбор он не едет, а текст подсказки по командам `slog`-ом
|
||||
выглядел бы хуже, чем есть.
|
||||
|
||||
## Сообщение
|
||||
|
||||
@@ -103,6 +106,7 @@ OpenSpec.
|
||||
| Когда добавляем | Поля |
|
||||
| --- | --- |
|
||||
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
|
||||
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
|
||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||
| на запись об ошибке | `error` |
|
||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||
|
||||
@@ -75,8 +75,10 @@
|
||||
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
||||
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
|
||||
`404`; путь внутри корня в приложение не проваливается никогда. Корней
|
||||
сегодня четыре — `/api/` у хранилища, `/app/` у приложения, `/auth/` у входа,
|
||||
`/_/` у панели, — плюс `/health` и `/metrics` отдельными адресами. Приложение
|
||||
сегодня три — `/api/` у хранилища, `/app/` у приложения, `/_/` у панели, —
|
||||
плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят
|
||||
2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями
|
||||
вне корней. Приложение
|
||||
уехало из общего `/api/` решением владельца 2026-08-15: пространство
|
||||
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
|
||||
нашим. Перечень корней сервису не описывают, а из него **порождают**
|
||||
@@ -104,9 +106,11 @@
|
||||
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
|
||||
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
|
||||
не `Response`.
|
||||
- **Сессия живёт кукой `transcriber_session`**, и приложение её не читает: кука
|
||||
`HttpOnly`, браузер шлёт её сам, а вошедшего экран узнаёт по ответу API. Норма
|
||||
— [access](../../openspec/specs/access/spec.md).
|
||||
- **Сессии у сервиса нет вовсе**, и приложение не хранит ничего: кто пришёл,
|
||||
называет заголовок обратного прокси, а приложение узнаёт его ответом API.
|
||||
Кук сервис не ставит — это свойство сторожится проверкой. Норма —
|
||||
[access](../../openspec/specs/access/spec.md), решение —
|
||||
[ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||
|
||||
## Показ ошибок и состояний
|
||||
|
||||
|
||||
+39
-10
@@ -271,15 +271,33 @@ capability, и третий смысл развёл бы одно слово п
|
||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
|
||||
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
|
||||
файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
|
||||
файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт
|
||||
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
|
||||
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
|
||||
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
|
||||
сужает: создание записи разрешено только контексту обмена OIDC
|
||||
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
|
||||
Без этого сужения закрытие API обходится двумя запросами — завести себе
|
||||
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
|
||||
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
|
||||
- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают.
|
||||
`202608120001` выключил вход по паролю и одноразовый код; `202608220001`
|
||||
довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление,
|
||||
чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища
|
||||
означает «только владелец панели».
|
||||
|
||||
Правку и удаление умолчание библиотеки открывало владельцу записи
|
||||
(`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой
|
||||
предъявления жил под корнем приложения, и браузер до поверхности хранилища не
|
||||
дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи
|
||||
лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого
|
||||
имени. Наш код читает и заводит запись мимо правил, панель работает
|
||||
суперпользователем, своих экранов профиля сервис не заводит.
|
||||
|
||||
- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом,
|
||||
заведена шагом `202608220001`. В ней логин человека **у провайдера** — то
|
||||
значение, которым его называет обратный прокси заголовком. По нему запись
|
||||
ищется и по нему же заводится при первом обращении.
|
||||
|
||||
Почта в той же коллекции переведена в необязательную тем же шагом: провайдер
|
||||
не обязан её приносить, а ключом она не служит. Уникальность почты держится
|
||||
**частичным** индексом (`WHERE email != ''`), поэтому записи без почты
|
||||
уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с
|
||||
пустым ключом схема не примет вовсе.
|
||||
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
@@ -324,6 +342,14 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
|
||||
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
|
||||
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
||||
|
||||
**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при
|
||||
подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище
|
||||
ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и
|
||||
тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь
|
||||
одновременно открытых карточек выбирают его целиком. Требование к контуру,
|
||||
которое отсюда следует, записано в [security.md](security.md), «Периметр»:
|
||||
`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать.
|
||||
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
|
||||
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
||||
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
||||
@@ -339,9 +365,12 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||
| Срок жизни сессии | нормирует [access](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
|
||||
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||
| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки |
|
||||
|
||||
Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии,
|
||||
потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся
|
||||
вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом
|
||||
запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться.
|
||||
|
||||
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||
|
||||
+15
-13
@@ -21,7 +21,7 @@
|
||||
| --- | --- |
|
||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня не работает вовсе: домен целиком стоит за обратным прокси, и запрос программы отбивает он, не доходя до сервиса. Токен и правило прокси мимо входа приносит `api-tokens` |
|
||||
|
||||
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
|
||||
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
|
||||
@@ -61,10 +61,13 @@ Telegram.
|
||||
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
|
||||
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
|
||||
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
|
||||
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось
|
||||
2026-08-11 вместе с решением про PocketBase: в панель администратора владелец
|
||||
входит своим паролем, потому что подпустить к ней внешнего провайдера
|
||||
PocketBase не даёт.
|
||||
провайдер, свою регистрацию и свои пароли не делаем. Своя строка учётной
|
||||
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
|
||||
имя, названное провайдером, — заводит строку при первом обращении под новым
|
||||
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
|
||||
сервис не решает никогда. Одно исключение появилось 2026-08-11 вместе с
|
||||
решением про PocketBase: в панель администратора владелец входит своим
|
||||
паролем, потому что подпустить к ней внешнего провайдера PocketBase не даёт.
|
||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||
обрабатываем.
|
||||
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
||||
@@ -97,11 +100,10 @@ Telegram.
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
|
||||
токеном, получает идентификатор записи и читает её карточку
|
||||
`GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
|
||||
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только
|
||||
предъявившему сессию OIDC: анонимный запрос всеми адресами отклоняется. Своего
|
||||
входа у программы нет — его заводит `api-tokens`. Записи при этом
|
||||
разграничены: программа с чужой сессией видит только записи того, чью сессию
|
||||
предъявила.
|
||||
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только тому, кого
|
||||
назвал доверенный источник: неузнанный запрос всеми адресами отклоняется.
|
||||
Своего способа представиться у программы нет — его заводит `api-tokens`.
|
||||
Записи при этом разграничены: видны только записи того, чьим именем пришли.
|
||||
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
|
||||
получает признак остановки с причиной, и карточка записи отдаёт признак и
|
||||
причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
|
||||
@@ -119,6 +121,6 @@ Telegram.
|
||||
задача `pocketbase-storage` 2026-08-12
|
||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
|
||||
держит хранилище, файлы и панель владельца. Схема и
|
||||
раскладка — [database.md](database.md). Учётные записи она хранит и получает от
|
||||
Authelia своим провайдером OIDC, но источником их не становится: заводит и
|
||||
проверяет людей по-прежнему Authelia.
|
||||
раскладка — [database.md](database.md). Учётные записи она хранит, а заводит их
|
||||
сервис по имени, названному Authelia; источником людей она при этом не
|
||||
становится: заводит и проверяет их по-прежнему Authelia.
|
||||
|
||||
+24
-15
@@ -172,13 +172,15 @@
|
||||
Форма: `<тема>: <вопрос> (<откуда>)`.
|
||||
|
||||
- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
|
||||
в проверках. Подставных провайдера OIDC в репозитории теперь два — `cmd/oidcstub`
|
||||
и `fakeProvider` в `internal/controller/http/login_test.go`, — с теми же
|
||||
адресами и той же посылкой про `email_verified`, и они уже разошлись в мелочи
|
||||
(`token_type` «bearer» против «Bearer»). Тем же вопросом судится подставной
|
||||
распознаватель. Записанной конвенции о единственном доме подставных внешних
|
||||
собеседников у проекта нет, поэтому спрашивать надо, а не считать нарушением
|
||||
(ревью задачи про заглушку OIDC, 2026-08-15).
|
||||
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
|
||||
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
|
||||
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
|
||||
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте
|
||||
`cmd/devtools proxy`, а проверки ставят заголовок сами и подставного собеседника
|
||||
не держат вовсе. Тем же вопросом судится подставной распознаватель. Записанной
|
||||
конвенции о единственном доме подставных внешних собеседников у проекта нет,
|
||||
поэтому спрашивать надо, а не считать нарушением (ревью задачи про заглушку
|
||||
OIDC, 2026-08-15).
|
||||
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||||
@@ -294,13 +296,14 @@ API и имя не откатываются обратной правкой по
|
||||
день, и утверждения о росте остаются условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||
отдан внешней программе, и она вне нашей границы;
|
||||
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
|
||||
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
|
||||
выкладки вне репозитория, и по коду её не проверить
|
||||
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
|
||||
сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
|
||||
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
|
||||
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
|
||||
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
|
||||
- `security`: поведение браузера с куками — применение `SameSite`, приём
|
||||
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
|
||||
этого рода остаются гипотезами.
|
||||
- `security`: поведение браузера с куками. Своих кук сервис больше не ставит
|
||||
(2026-08-22), и класс сузился до кук, которые ставит панель хранилища; браузера
|
||||
в прогоне нет, и находки этого рода остаются гипотезами.
|
||||
|
||||
**Перестали проверять сознательно:**
|
||||
|
||||
@@ -321,8 +324,14 @@ API и имя не откатываются обратной правкой по
|
||||
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
|
||||
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
|
||||
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
|
||||
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
|
||||
тоже недоступен: сессию в прогоне выдать нечем.
|
||||
остановку; нельзя — расшифровку и заливку.
|
||||
|
||||
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
|
||||
сессию в прогоне выдать было нечем; теперь заголовок ставит `cmd/devtools
|
||||
proxy`, и живьём проверяются узнавание, заведение учётной записи первым
|
||||
обращением, отказ с недоверенного адреса и отказ старта на пустом перечне.
|
||||
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
|
||||
контуре (см. «Не проверит ни один проход»).
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
|
||||
+106
-53
@@ -3,14 +3,21 @@
|
||||
## Периметр
|
||||
|
||||
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
|
||||
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Без
|
||||
входа открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
|
||||
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
|
||||
`trusted-header-login`, называет она его **заголовком, который ставит обратный
|
||||
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
|
||||
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
|
||||
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
|
||||
**каждый** запрос, и отзыв доступа действует со следующего.
|
||||
|
||||
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
|
||||
принадлежащий ни одному корню сервиса. Иначе не вошедший не дошёл бы до входа
|
||||
вовсе: закрытая сессией разметка отдала бы ему отказ вместо экрана. Данных
|
||||
открытость не касается — всякий адрес под корнем приложения сессии по-прежнему
|
||||
требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
|
||||
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
|
||||
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
|
||||
открытость не касается — всякий адрес под корнем приложения узнанного
|
||||
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||
|
||||
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
|
||||
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
|
||||
@@ -58,13 +65,49 @@ Telegram — связи чата с учётной записью сервис
|
||||
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
|
||||
прокси живёт в `pet-project-server`, вне этого репозитория.
|
||||
|
||||
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
|
||||
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
|
||||
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
|
||||
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
|
||||
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
|
||||
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
|
||||
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
|
||||
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||||
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||||
секрет в настройки коллекции пользователей, и чтение файла базы становилось
|
||||
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
|
||||
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
|
||||
|
||||
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
|
||||
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
|
||||
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
|
||||
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
|
||||
и аноним войдёт под любым именем. Половину этой беды сервис закрывает сам:
|
||||
запрос с двумя значениями `Remote-User` не узнаёт никого. Вторую половину
|
||||
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
|
||||
репозитория `pet-project-server`, и **требование к нему такое — заголовки
|
||||
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||||
человек.
|
||||
|
||||
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.**
|
||||
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса
|
||||
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а
|
||||
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси,
|
||||
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика
|
||||
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером
|
||||
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого
|
||||
соединения.
|
||||
|
||||
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||||
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||||
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
|
||||
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
|
||||
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
|
||||
именно поэтому.
|
||||
|
||||
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
|
||||
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
|
||||
после ухода прежнего владельца, и тогда новый человек при первом же обращении
|
||||
попадает в **существующую** запись и получает весь её архив — самое
|
||||
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
|
||||
может: неизменяемого признака заголовок не приносит. Не допускать
|
||||
переиспользования — работа провайдера, и это принятая цена, записанная в
|
||||
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
|
||||
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
|
||||
нечем ни слить, ни убрать.
|
||||
|
||||
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||||
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
|
||||
@@ -79,10 +122,11 @@ Telegram — связи чата с учётной записью сервис
|
||||
|
||||
| Вход | Канал | Кто может слать |
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой записи |
|
||||
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой вошедший; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой вошедший; значение вне закрытого перечня даёт `400` |
|
||||
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения |
|
||||
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
|
||||
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
|
||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||
|
||||
@@ -92,7 +136,6 @@ Telegram — связи чата с учётной записью сервис
|
||||
| Вход | Канал | Кто может слать | Чья задача |
|
||||
| --- | --- | --- | --- |
|
||||
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
|
||||
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
|
||||
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
|
||||
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
|
||||
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
|
||||
@@ -179,39 +222,48 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
|
||||
([database.md](database.md), «Настройки с числовым значением»).
|
||||
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
||||
бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
|
||||
доходит до сервиса, — не значил бы ничего.
|
||||
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
||||
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
||||
предъявить ту же сессию.
|
||||
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
|
||||
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
|
||||
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
|
||||
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
|
||||
здесь «сессия → токен файла → ссылка».
|
||||
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
|
||||
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
|
||||
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||||
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||||
отзыв доступа у Authelia действует со следующего обращения.
|
||||
Предъявленный собственный токен хранилища побеждает заголовок: им работает
|
||||
владелец панели, и подмена его учётной записью пользователя отобрала бы у него
|
||||
панель. Протухший и негодный токен предъявленными не считаются.
|
||||
**Область узнавания сужена** до корня приложения и адреса выдачи файлового
|
||||
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный
|
||||
переписал бы себе ключ учётной записи на чужое имя.
|
||||
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||||
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||||
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом
|
||||
схемы `202608220001`.
|
||||
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла
|
||||
помечено защищённым, правило просмотра коллекции пускает только владельца
|
||||
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного
|
||||
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это
|
||||
единственное значение, переживающее запрос**, и на его срок отзыв доступа до
|
||||
файловой ссылки не доходит.
|
||||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||
2026-08-12.
|
||||
- **Заведение учётной записи** — только входом у провайдера. Собственное
|
||||
создание записи, вход по паролю, одноразовый код и восстановление доступа
|
||||
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
|
||||
без этого закрытия вход обходился бы двумя запросами.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
|
||||
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
|
||||
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по
|
||||
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление
|
||||
доступа и продление — ни один не даёт доступа и не меняет учётной записи:
|
||||
хранилище заводит коллекцию пользователей открытой, и без этого закрытия
|
||||
узнавание обходилось бы двумя запросами.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||||
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||||
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||
эти адреса не несут.
|
||||
- **Приложение** — его разметка и ресурсы открыты без сессии, и ограничителя
|
||||
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
|
||||
частоты на них нет: правило заведено под корень приложения, а раздача стоит
|
||||
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
|
||||
для всех и собраны до всякого запроса. По ответу нельзя узнать, вошёл ли
|
||||
кто-то, — вошедшему и не вошедшему отдаётся одно и то же.
|
||||
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
|
||||
кто-то, — узнанному и неузнанному отдаётся одно и то же.
|
||||
|
||||
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||||
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||||
@@ -222,9 +274,9 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
| Механизм | Что даёт | Чья задача |
|
||||
| --- | --- | --- |
|
||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
||||
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
|
||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
|
||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
|
||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||
|
||||
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
||||
@@ -252,8 +304,8 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
наравне с записью — норму держит спека `storage`.
|
||||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
|
||||
приложение от чужого имени.
|
||||
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
|
||||
из базы вместе с собственным входом.
|
||||
|
||||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||||
@@ -335,13 +387,14 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
|
||||
Ansible — не наша граница.
|
||||
- **Машина разработчика и то, что он на ней поднимает.** С 2026-08-15 в
|
||||
репозитории лежит `cmd/oidcstub` — подставной провайдер OIDC, который выдаёт
|
||||
сессию всякому спросившему и не сверяет ни секрета клиента, ни проверочного
|
||||
кода PKCE. Двух вещей это не отменяет, и обе проверяемы: в образ он не едет
|
||||
(ступень собирает `./cmd/transcriber` поимённо), а слушает петлевой адрес.
|
||||
Периметра выкладки заглушка поэтому не касается; кто поднял её у себя в чужой
|
||||
сети, отвечает за это сам.
|
||||
- **Машина разработчика и то, что он на ней поднимает.** В репозитории лежит
|
||||
`cmd/devtools` — оснастка разработчика; её подкоманда `proxy` встаёт на место
|
||||
контура: ставит заголовок `Remote-User` и переправляет запрос сервису, не
|
||||
проверяя ничего. С 2026-08-15 по 2026-08-22 ту же роль играл `cmd/oidcstub`,
|
||||
подставной провайдер OIDC. Двух вещей это не отменяет, и обе проверяемы: в
|
||||
образ оснастка не едет (ступень собирает `./cmd/transcriber` поимённо), а
|
||||
слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял
|
||||
её у себя в чужой сети, отвечает за это сам.
|
||||
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
||||
доверяем полностью.
|
||||
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
||||
|
||||
Reference in New Issue
Block a user