From d030b04cf2858405fffde83a28d44591c0e77c51 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 23:48:14 +0500 Subject: [PATCH] docs(infra): secrets rotation policy + procedure (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/Secrets_Rotation_Policy.md | 301 ++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 docs/Secrets_Rotation_Policy.md diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md new file mode 100644 index 00000000..cdcc39d5 --- /dev/null +++ b/docs/Secrets_Rotation_Policy.md @@ -0,0 +1,301 @@ +# 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 -f up -d --force-recreate --no-deps `. + +--- + +## 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 ` + (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 WITH PASSWORD '';` внутри контейнера postgres + (`docker compose -p exec postgres psql -U -d -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=` если + 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 бесполезным.