# 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 | 6–12 мес (или по 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 бесполезным.
