feat(auth): store the token signing secret in the metadata layer

- app_settings table (PostgreSQL migration 000010, SQLite 000002)
- internal/settings repository (Get/Set/Ensure) wired into store.Repos
- secret generated with crypto/rand on first start and persisted; reused on restart
- drop required JWT_SECRET from env/config; app.New takes a TokenManager and
  storage.Open takes the resolved secret (URL_SECRET still overrides)
- docs updated
This commit is contained in:
iu
2026-10-11 10:18:31 +03:00
parent 176a8d8ced
commit cb272398ce
29 changed files with 489 additions and 30 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,76 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние:
- `JWT_SECRET` обязателен в env (`config.Load`), используется в `auth.NewTokenManager`
(HS256-подпись access-токенов) и как fallback для `URL_SECRET` в `storage.Open`.
- Refresh-токены не подписываются секретом (случайные, хранятся хэшем).
- Есть два бэкенда метаданных (`postgres`, `sqlite`) за интерфейсом `Repository` и
фабрикой `store.NewPostgres/NewSQLite`; миграции раздельные (pg-набор и `migrations/sqlite`).
- Порядок старта в `cmd/server`: миграции → метаданные → storage → app.
## Goals / Non-Goals
**Goals:**
- Хранить секрет приложения в слое метаданных; генерировать при первом использовании.
- Убрать обязательный `JWT_SECRET` из окружения.
- Единый стабильный секрет на инсталляцию; корректное поведение при конкурентном старте.
**Non-Goals:**
- Шифрование секрета в БД (БД доверенная).
- UI/команда ротации секрета.
- Миграция существующих env-секретов в БД.
- Изменение формата токенов или наблюдаемого поведения аутентификации для клиента.
## Decisions
### D1. Таблица `app_settings`
`app_settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at, updated_at)` в обоих
бэкендах. Ключ `jwt_secret`. Отдельные миграции: PostgreSQL `000010_app_settings`,
SQLite `000002_app_settings` (консолидированная 000001 уже применена на существующих БД).
### D2. Репозиторий `internal/settings`
Интерфейс `Repository { Get(ctx,key) (value string, ok bool, err error); Set(ctx,key,value) error; Ensure(ctx,key, generate func() (string,error)) (string, error) }`.
Реализации pg и sqlite. `Ensure`: `SELECT` → если есть, вернуть; иначе сгенерировать,
`INSERT ... ON CONFLICT(key) DO NOTHING`, затем `SELECT` (единый секрет при гонке).
Добавляется в `store.Repos`.
### D3. Генерация секрета
`crypto/rand` 32 байта → base64url (без padding). Значение хранится как строка; в
TokenManager и Signer передаётся как есть.
### D4. Порядок старта и проводка
`cmd/server`: миграции → метаданные → `secret := repos.Settings.Ensure("jwt_secret", generate)`
→ `tm := auth.NewTokenManager(secret, accessTTL, refreshTTL)` → `storage.Open(ctx, cfg, secret)`
(где `URL_SECRET`, если задан, имеет приоритет, иначе используется `secret`) → `app.New`.
`app.New` перестаёт строить TokenManager из конфига и принимает готовый `*auth.TokenManager`
(или секрет).
### D5. Конфиг
Поле `Auth.JWTSecret` и проверка обязательности удаляются; `JWT_ACCESS_TTL`/`JWT_REFRESH_TTL`
остаются. `URL_SECRET` остаётся необязательным. `storage.Open` получает секрет параметром
вместо чтения из конфига.
## Risks / Trade-offs
- [Смена источника секрета у существующих деплоев] → access-токены инвалидируются, но
refresh-токены остаются валидными (не подписаны секретом), клиент молча обновляет access;
в худшем случае — перелогин. Отмечаем в доках.
- [Конкурентный первый старт на общей БД] → `INSERT ... ON CONFLICT DO NOTHING` + повторный
`SELECT` (D2).
- [Секрет в открытом виде в БД] → БД доверенная; шифрование — вне рамок.
- [Тихая смена поведения] → при `URL_SECRET` не задан подпись URL зависит от секрета из БД;
явный `URL_SECRET` снимает зависимость.
## Migration Plan
1. Добавить таблицу и репозиторий секретов; сид отсутствует — генерируется при старте.
2. Переключить `cmd/server`/`app`/`storage` на секрет из метаданных; убрать `JWT_SECRET`.
3. Обновить доки и `.env.example`.
4. Откат: вернуть чтение `JWT_SECRET` из env (код), данные не меняются.
## Open Questions
- Нужна ли операция ротации секрета (сброс значения и инвалидация access-токенов).
@@ -0,0 +1,36 @@
# Proposal
## Why
`JWT_SECRET` сейчас обязателен в окружении: его нужно задавать вручную и одинаково на
всех инстансах. Хранение секрета в слое метаданных с автогенерацией убирает обязательный
секрет из env и даёт стабильное значение на инсталляцию.
## What Changes
- Убрать обязательный `JWT_SECRET` из конфига и окружения.
- Добавить таблицу `app_settings` (key/value) в оба бэкенда метаданных (PostgreSQL и
SQLite) и репозиторий чтения/создания секрета.
- При старте после открытия метаданных читать секрет или генерировать его
(`crypto/rand`) и сохранять; использовать для `TokenManager` и как fallback при подписи
URL объектов.
- `URL_SECRET` остаётся необязательным env-оверрайдом.
- Обновить `.env.example`, `project.md`, `README.md`.
## Capabilities
### New Capabilities
- `app-secrets`: хранение секретов приложения (JWT) в слое метаданных с автогенерацией при первом использовании.
### Modified Capabilities
<!-- Нет изменений требований существующих спек: для клиента поведение аутентификации не меняется. -->
## Impact
- `backend/internal/config` (убрать `JWTSecret` и его обязательность).
- `backend/internal/app`, `backend/cmd/server` (порядок старта: метаданные → секрет →
storage/app).
- `backend/internal/storage` (fallback-секрет для подписи URL).
- Новая settings-репозиция (`internal/settings`), миграции PostgreSQL и SQLite.
- Тесты (контрактные, e2e), `.env.example`, `project.md`, `README.md`.
- Не затрагивает формат данных клиента.
@@ -0,0 +1,48 @@
# Spec Delta
## Purpose
Хранение секретов приложения в слое метаданных с автогенерацией: единый стабильный
секрет на инсталляцию без обязательной настройки окружения.
## ADDED Requirements
### Requirement: Хранение секрета приложения
Слой метаданных SHALL хранить секрет подписи токенов приложения и SHALL возвращать один
и тот же секрет при последующих запусках.
#### Scenario: Первый запуск
- **WHEN** секрет ещё не сохранён
- **THEN** система генерирует случайный секрет и сохраняет его в слое метаданных
#### Scenario: Повторный запуск
- **WHEN** секрет уже сохранён
- **THEN** система использует сохранённый секрет и не генерирует новый
### Requirement: Отсутствие ручного секрета
Система SHALL работать без секрета, заданного в окружении, и SHALL не требовать его
наличия при старте.
#### Scenario: Секрет не задан в окружении
- **WHEN** переменная секрета не задана
- **THEN** система запускается, используя секрет из слоя метаданных
### Requirement: Конкурентный первый запуск
При одновременном первом запуске нескольких инстансов на общей базе метаданных система
SHALL обеспечивать единый сохранённый секрет для всех инстансов.
#### Scenario: Гонка первого запуска
- **WHEN** два инстанса одновременно обнаруживают отсутствие секрета
- **THEN** сохраняется ровно один секрет, и оба инстанса используют его
### Requirement: Использование секрета для подписи URL
При отсутствии отдельного секрета подписи URL система SHALL использовать секрет из слоя
метаданных.
#### Scenario: URL_SECRET не задан
- **WHEN** отдельный секрет подписи URL не задан
- **THEN** подпись URL объектов использует секрет приложения из слоя метаданных
#### Scenario: URL_SECRET задан
- **WHEN** отдельный секрет подписи URL задан
- **THEN** он используется независимо от секрета приложения
@@ -0,0 +1,15 @@
# Tasks
## 1. Репозиторий секретов и миграции
- [x] 1.1 Добавить таблицу `app_settings` миграциями: PostgreSQL `000010_app_settings` и SQLite `000002_app_settings`; проверка — применение на временных БД и идемпотентность.
- [x] 1.2 Реализовать `internal/settings` (`Get`/`Set`/`Ensure`) для pg и sqlite и добавить в `store.Repos`; проверка — контрактный тест: `Ensure` идемпотентен и возвращает одно значение.
## 2. Проводка и конфиг
- [x] 2.1 Убрать `Auth.JWTSecret` и его обязательность из конфига; `app.New` принимает готовый `*auth.TokenManager`; `storage.Open` принимает секрет-fallback; проверка — `go build ./...`.
- [x] 2.2 В `cmd/server` после открытия метаданных получить/сгенерировать секрет и собрать `TokenManager` и storage; проверка — e2e: перезапуск процесса даёт тот же секрет (токен, выпущенный до перезапуска, валиден после).
## 3. Документация
- [x] 3.1 Обновить `.env.example`, `project.md`, `README.md`: убрать `JWT_SECRET`, описать автогенерацию секрета в слое метаданных и роль `URL_SECRET`; проверка — документированный запуск без `JWT_SECRET` стартует.