gendesign/docs/Secrets_Rotation_Policy.md
Light1YT d030b04cf2
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
docs(infra): secrets rotation policy + procedure (#78)
Compliance-doc: реестр классов секретов (CI/CD + prod runtime), процедуры ротации
per-class с downtime-эффектом, периодичность, quarterly audit-чеклист. OIDC verdict:
future (Forgejo 10.0.3 без Actions OIDC token provider; deploy = прямой SSH, не cloud
federation). Флагает sshkey.txt (PR #391) достижимый в git-истории — deploy-key
скомпрометирован, нужна ротация. Значения — в vault meta/00_credentials.md (не в репо).

Closes #78
2026-06-13 23:48:14 +05:00

301 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Secrets Rotation Policy & Procedure
**Issue:** #78 (B6-4) · **Owner:** `devops-engineer` · **Created:** 2026-06-13
Версионированная (in-repo) копия политики ротации секретов. Источник истины по
**значениям** секретов — Obsidian vault `meta/00_credentials.md` (вне репозитория,
владелец — Anton). Этот документ описывает **классы** секретов (имена, расположение,
процедуру ротации, downtime-эффект, периодичность) — **без значений**.
> ⚠️ Этот файл НЕ содержит и не должен содержать значений секретов. Только имена
> переменных, их расположение и процедуры. PR-ревью отклоняет любой diff с
> literal-секретом (`.claude/rules/git-pr.md` → security tripwire).
---
## 0. Модель хранения секретов
| Хранилище | Что лежит | В git? | Кто читает |
|---|---|---|---|
| **Forgejo repo secrets** (`lekss361/gendesign` → Settings → Actions → Secrets) | CI/CD: deploy SSH key, GHCR PAT, bot-токены, DSN, прод API-ключи прокидываемые в `.env.runtime` | ❌ нет | Forgejo Actions runner |
| **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner |
| **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) |
| **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) |
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
| **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек |
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
**Правило редактирования `.env` / `.env.runtime`:** только in-place (`sed`/append).
Полная перезапись файла затрёт user-managed секреты, которых нет в репо. См.
`.claude/rules/deploy.md` → «Полностью перезаписывать `.env.runtime` — только `sed`».
**Правило применения изменений:** `docker compose restart` НЕ перечитывает `env_file:`.
После правки env — `docker compose -p <project> -f <file> up -d --force-recreate --no-deps <svc>`.
---
## 1. Реестр классов секретов
Сгруппировано по классу. Колонка «Ротация» ссылается на процедуру в §2.
### 1.1 CI/CD (Forgejo / GitHub repo secrets)
| Имя | Где живёт | Назначение | Класс ротации |
|---|---|---|---|
| `DEPLOY_SSH_KEY` | Forgejo secrets + GitHub secrets | Приватный SSH-ключ для `appleboy/ssh-action` → VPS deploy. **Используется в** `deploy.yml`, `deploy-tradein.yml`, `deploy-obsidian.yml` | **A** (SSH key) |
| `DEPLOY_HOST` / `DEPLOY_USER` / `DEPLOY_PORT` | Forgejo + GitHub secrets | Адрес/юзер/порт VPS. Не «секрет» в строгом смысле, но хранится как secret | **A** |
| `GHCR_PAT` | Forgejo secrets | GitHub Container Registry PAT (`docker login ghcr.io -u lekss361`). Push образов + pull на VPS | **B** (PAT) |
| `FORGEJO_BOT_QA_TOKEN` | Forgejo secrets | bot-qa PAT (`write:issue`) для `stale-claims.yml` / `scripts/cleanup-stale-claims.sh` | **B** |
| `GLITCHTIP_BACKEND_DSN` | Forgejo secrets | Backend Sentry DSN → пишется в `.env.runtime` на deploy | **C** (DSN) |
| `GLITCHTIP_FRONTEND_DSN` | Forgejo secrets | Build-arg `NEXT_PUBLIC_GLITCHTIP_DSN` для frontend образа | **C** |
| `OBJECTIVE_API_KEY` | Forgejo secrets | Объектив live-scraper (`sync_all_groups`, #307). Пустой = no-op | **D** (3rd-party API key) |
| `OPENAI_API_KEY` | Forgejo secrets | LLM-чат (#960/#957). UNSET по умолчанию → фича dormant | **D** |
> Дополнительно во вне-репозиторном `FORGEJO_TOKEN` (личный PAT Anton) живёт доступ
> к Forgejo API из CLI/скриптов. Класс **B**.
### 1.2 Прод runtime — main стек (`/opt/gendesign/.env` + `backend/.env.runtime`)
| Имя | Файл | Назначение | Класс ротации |
|---|---|---|---|
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
| `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** |
| `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** |
| `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** |
| `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |
| `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) |
| `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** |
| `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** |
| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) |
### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`)
| Имя | Назначение | Класс ротации |
|---|---|---|
| `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** |
| `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** |
| `YANDEX_GEOCODER_API_KEY` | Yandex Geocoder (25k req/day) | **D** |
| `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** |
| `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) |
| `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** |
| `COOKIE_ENCRYPTION_KEY` | Шифрование cookie-стейта браузера | **F** |
### 1.4 Proxy / basic_auth (Caddy)
| Артефакт | Где | Назначение | Класс ротации |
|---|---|---|---|
| `caddy/users.caddy.snippet` | **in git** | bcrypt-хеши пилотных юзеров (basic_auth gate, realm `GenDesign Pilot`) | **H** (basic_auth) |
bcrypt-хеши — односторонние, не plaintext-секреты, поэтому намеренно живут в репо.
Ротация полностью описана в `docs/runbooks/basic_auth_management.md`.
---
## 2. Процедуры ротации по классам
Общие принципы:
- Любая ротация = vault entry в `meta/00_credentials.md` (audit-log: что, когда, кто).
- Меняем `.env`/`.env.runtime` **только** `sed`/append, не перезаписываем файл целиком.
- После правки runtime-env прод-сервис поднимаем через `up -d --force-recreate --no-deps <svc>`
(compose project: `gendesign` / `gendesign-tradein` / `gendesign-obsidian`).
### Класс A — Deploy SSH key (`DEPLOY_SSH_KEY` + host/user/port)
**Downtime:** нет (ключ используется только во время CI deploy-шага).
1. На VPS под deploy-юзером сгенерировать новую keypair:
`ssh-keygen -t ed25519 -C "forgejo-deploy-$(date +%Y%m%d)" -f ~/.ssh/forgejo_deploy_new`.
2. Добавить **новый публичный** ключ в `~/.ssh/authorized_keys` на VPS (рядом со старым — оба валидны на время cutover).
3. Обновить secret `DEPLOY_SSH_KEY` (приватная часть) в **обоих** местах: Forgejo repo secrets **и** GitHub repo secrets (obsidian-стек использует GitHub Actions).
4. Прогнать `workflow_dispatch` на `deploy.yml` — убедиться, что deploy зелёный с новым ключом.
5. Удалить **старый** публичный ключ из `authorized_keys`.
6. Vault entry.
> ⚠️ **КРИТИЧНО (исторический инцидент):** файл `sshkey.txt` был закоммичен и удалён
> в PR #391 (commit `6f37240`), но **остаётся в git-истории** — этот deploy-ключ
> следует считать **скомпрометированным** и ротировать по процедуре выше как
> приоритетную задачу (если ещё не сделано). Удаление файла из tree НЕ убирает
> blob из истории; чистка истории (`git filter-repo`) — отдельная операция, но
> ротация ключа важнее (после ротации старый blob бесполезен).
### Класс B — PAT (`GHCR_PAT`, `FORGEJO_BOT_QA_TOKEN`, `FORGEJO_TOKEN`)
**Downtime:** нет (если новый PAT выпущен до отзыва старого).
1. Выпустить новый PAT в соответствующем сервисе:
- `GHCR_PAT`: GitHub → Settings → Developer settings → Tokens (classic) с `write:packages`, `read:packages`.
- `FORGEJO_BOT_QA_TOKEN`: войти под bot-qa в Forgejo → Settings → Applications → minimum `write:issue`.
- `FORGEJO_TOKEN`: личный PAT Anton (Forgejo → Applications).
2. Обновить значение в Forgejo repo secrets (для CI) / в keychain (для CLI).
3. Триггернуть зависимый workflow (`deploy.yml` для GHCR, `stale-claims.yml` для bot-qa) — проверить зелёный.
4. Отозвать старый PAT в сервисе.
5. Vault entry.
> `deploy.yml`/`deploy-tradein.yml` делают `docker login ghcr.io` повторно на каждом
> deploy, так что ротация `GHCR_PAT` подхватывается со следующим деплоем без ручного
> вмешательства на VPS.
### Класс C — GlitchTip DSN (`GLITCHTIP_BACKEND_DSN`, `GLITCHTIP_FRONTEND_DSN`, `GLITCHTIP_DSN`)
**Downtime:** нет (пустой DSN = SDK no-op, события просто не шлются в окно ротации).
1. В GlitchTip UI (`errors.gendsgn.ru`) → Project Settings → Client Keys (DSN) → revoke + create new.
2. Backend: обновить `GLITCHTIP_BACKEND_DSN` в Forgejo secrets → deploy пропишет его в `.env.runtime` через `sed` и `--force-recreate` backend/worker/beat.
3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`.
4. Vault entry.
### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`)
**Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии).
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт).
2. Где живёт:
- `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`.
- `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI).
3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein).
4. Vault entry.
### Класс E — DB passwords (`POSTGRES_PASSWORD`, `TRADEIN_POSTGRES_PASSWORD`, `*_FDW_PASSWORD`, `*_READER_PASSWORD`, `COUCHDB_PASSWORD`, `GLITCHTIP_DB_PASS`)
**Downtime:** короткий — backend переподключается после смены (несколько секунд).
Главная роль (`gendesign` / `tradein`):
1. Сгенерировать пароль: `openssl rand -base64 24 | tr -d '/+' | head -c 24`.
2. `ALTER ROLE <role> WITH PASSWORD '<new>';` внутри контейнера postgres
(`docker compose -p <project> exec postgres psql -U <user> -d <db> -c "ALTER ROLE ..."`).
3. Обновить пароль в `.env` (`POSTGRES_PASSWORD`) / `.env.runtime` `sed`-ом.
4. `up -d --force-recreate --no-deps backend worker beat``caddy`/`frontend` если завязаны).
5. Vault entry.
Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`):
- Пароль живёт ТОЛЬКО в `.env.runtime`; роль создаётся passwordless SQL-миграцией.
- Меняем переменную (`GENDESIGN_FDW_PASSWORD` / `TRADEIN_READER_PASSWORD`) в `.env.runtime`,
затем повторно прогоняем idempotent-скрипт `ops/db-bootstrap/set_*_password.sql`
(deploy.yml/deploy-tradein.yml делают это автоматически на следующем деплое).
`COUCHDB_PASSWORD`: сменить в CouchDB (`_users` / admin) + `backend/.env.runtime` + `up -d --force-recreate couchdb` через `docker-compose.obsidian.yml`. Все LiveSync-клиенты потребуют новый пароль.
### Класс F — App secrets (`GLITCHTIP_SECRET`, `COOKIE_ENCRYPTION_KEY`, `JWT_SECRET`)
**Downtime / эффект:** смена инвалидирует выпущенные артефакты (сессии/токены/cookie).
1. Сгенерировать: `openssl rand -hex 32` (или `-base64 48`).
2. Обновить в соответствующем `.env`/`.env.runtime`.
3. `up -d --force-recreate` затронутого сервиса.
4. **Эффект:** `GLITCHTIP_SECRET` — разлогинит GlitchTip-сессии; `COOKIE_ENCRYPTION_KEY`
— инвалидирует сохранённый browser-стейт скрапера (потребуется re-login); `JWT_SECRET`
(см. §3, появится после B3-4) — инвалидирует все выданные JWT (все юзеры разлогинятся).
Планировать на low-traffic окно.
5. Vault entry.
### Класс G — Proxy creds (`SCRAPER_PROXY_URL` и др. `*_PROXY_URL`)
**Downtime:** нет для основного API; скраперы переключатся на новый прокси при следующем прогоне.
1. Получить новый прокси-endpoint/credentials у провайдера (mobileproxy и т.п.).
2. Обновить `SCRAPER_PROXY_URL` (и/или per-источник `CIAN_PROXY_URL`/`YANDEX_PROXY_URL`/`AVITO_PROXY_URL`) в tradein `.env.runtime` `sed`-ом.
3. `up -d --force-recreate --no-deps backend scraper` в tradein-стеке.
4. Vault entry.
> URL содержит `user:pass@host` — это секрет; не логировать целиком.
### Класс H — basic_auth (Caddy pilot users)
**Downtime:** нет. **Процедура полностью в `docs/runbooks/basic_auth_management.md`.**
Кратко: `scripts/auth/{add,remove}_user.sh` → правит `caddy/users.caddy.snippet`
(bcrypt-хеш, не plaintext) → commit → PR → merge → `deploy.yml` делает `caddy reload`.
При компрометации: `remove_user.sh` ASAP + новый credential + рассылка стейкхолдерам.
---
## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436
(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth.
Поле оставлено в `core/deps.py` только для быстрого rollback.
**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока
`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F**
(процедура: `sed` в `backend/.env.runtime``up -d --force-recreate --no-deps backend beat`,
downtime отсутствует). Фактический прод-прогон ротации — операционное действие
(Anton), не выполняется в рамках этого PR.
`JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует**
добавить в реестр (класс **F**) при внедрении JWT-аутентификации.
---
## 4. Периодичность
| Класс | Плановая ротация | Внеплановая (немедленно) |
|---|---|---|
| A — deploy SSH key | 12 мес | компрометация / уход члена команды / **#391 (см. §2-A)** |
| B — PAT | 612 мес (или по expiry провайдера) | утечка / смена команды |
| C — DSN | по необходимости | утечка DSN |
| D — 3rd-party API | по политике провайдера | утечка / подозрительная активность |
| E — DB password | 12 мес | компрометация / смена команды |
| F — app secret | 12 мес | компрометация |
| G — proxy | по сроку аренды прокси | блокировка/утечка |
| H — basic_auth | конец пилота / per-user | компрометация пароля |
**Триггеры внеплановой ротации (любой класс):**
- Уход/смена члена команды с доступом к vault или VPS.
- Секрет случайно попал в git / лог / скриншот / чат.
- Подозрение на компрометацию VPS или CI-runner.
---
## 5. Audit-чеклист (онбординг / периодический ревью, раз в квартал)
- [ ] Все CI-секреты в Forgejo (и GitHub-зеркало для obsidian) актуальны и не истекли.
- [ ] `.env` / `.env.runtime` на VPS — chmod 600, владелец deploy-юзер (`ls -l`).
- [ ] Ни один секрет не закоммичен: `git grep -nE '(password|secret|token|api_key)\s*=\s*\S' -- '*.env*' ':!*.example'` пуст в tree.
- [ ] `git ls-files | grep -iE '\.pem$|id_rsa|id_ed25519|sshkey'` — пусто.
- [ ] Deploy SSH key из #391 ротирован (старый публичный ключ удалён из `authorized_keys`).
- [ ] `meta/00_credentials.md` в vault совпадает с фактическими именами из §1 (нет «осиротевших» записей).
- [ ] Каждая ротация за период имеет audit-entry в vault.
- [ ] basic_auth: `scripts/auth/list_users.sh` совпадает с актуальным списком пилотов.
- [ ] Уехавшие члены команды отозваны везде (Forgejo, GitHub, VPS `authorized_keys`, vault-доступ).
---
## 6. OIDC для CI/CD deploy — вердикт
**Запрос #78:** перейти на OIDC для Actions-деплоя вместо long-lived `DEPLOY_SSH_KEY`.
**Вердикт: FUTURE (сейчас не реализуемо).**
- Деплой во всех трёх пайплайнах (`deploy.yml`, `deploy-tradein.yml`, `deploy-obsidian.yml`)
идёт через `appleboy/ssh-action` по SSH-ключу на Beget VPS. Это **прямой SSH на собственный
хост**, а не federation в облако (AWS/GCP/Azure) — классический сценарий «GHA OIDC → cloud
role» здесь не применим: конечная точка не понимает OIDC-токен, ей нужен SSH.
- Forgejo на проде — **v10.0.3** (Gitea 1.22 base). В этой версии Forgejo Actions **не
предоставляет** OIDC-токен-эндпоинт для workflow (`ACTIONS_ID_TOKEN_REQUEST_URL` /
`core.getIDToken()` отсутствуют; `id-token: write` permission не реализован). Поиск по репо
подтверждает: ни одного OIDC/`id-token` упоминания в workflow нет.
- Даже на новых Forgejo OIDC-функциональность — это про Forgejo **как OIDC-провайдер для
внешних сервисов**, а не drop-in замена SSH-ключа для деплоя на свой VPS.
**Что реально снижает риск SSH-ключа сейчас (рекомендации, не входят в этот PR):**
1. **Ротация ключа из #391** (см. §2-A) — приоритет: ключ скомпрометирован историей.
2. Ограничить ключ в `authorized_keys` через `command=`/`restrict`/`from=<runner-ip>` если
IP runner-а стабилен (force-command на deploy-скрипт вместо полного shell).
3. Отдельный deploy-юзер с минимальными правами (не root), `sudo` только на нужные docker-команды.
4. Регулярная плановая ротация по классу A (12 мес).
**Пересмотреть OIDC**, когда: (а) Forgejo обновится до версии с OIDC-token-провайдером
для Actions, **и** (б) деплой-таргет сможет валидировать OIDC (напр. миграция на облачный
registry/host с IAM). До тех пор — short-lived SSH через `command=`-ограничение + плановая
ротация закрывают практический риск.
---
## 7. Что вне этого документа (Anton territory)
- **Реестр значений секретов** — vault `meta/00_credentials.md` (дополнить именами из §1, если расходится).
- **Фактическая ротация на проде** — операционное действие (SSH на VPS, `ALTER ROLE`, правка `.env`), выполняет владелец инфраструктуры.
- **Audit-entry per rotation** — Obsidian vault.
- **Чистка `sshkey.txt` из git-истории** (`git filter-repo`) — отдельная операция; ротация ключа (§2-A) важнее и делает старый blob бесполезным.