Secrets management — central source of truth
Objectif : une gestion propre et durable de TOUS les secrets de l’infrastructure GenAI / CoursIA, pour que le drift (un secret édité à un endroit mais pas ailleurs) soit mécaniquement impossible.
Architecture — .secrets/master.env + render
.secrets/master.env <- SOURCE UNIQUE (éditer ici)
HF_TOKEN, OPENAI_API_KEY, COMFYUI_VIDEO_TOKEN, QDRANT_API_KEY, ... (19 clés partagées)
scripts/secrets/render_envs.py
--bootstrap one-shot : lit les .env éparpillés, écrit master.env
(défaut) sync : propage master.env -> chaque .env (15 cibles)
--check gate : exit 1 si un .env drift de master (audit local)
docker-configurations/services/*/.env <- CONFIG service (ports, paths, GPU)
MyIA.AI.Notebooks/GenAI/.env <- CONFIG notebooks + secrets synchronisés
Principe : master.env ne contient que les secrets partagés (un consommateur ≠, une valeur commune). La CONFIG service-spécifique (ports, chemins, GPU ids, noms de modèles, TZ) reste dans chaque .env et n’est jamais touchée par le render. Les mots de passe par instance (chaque ComfyUI / Forge a le sien) ne sont PAS centralisés — voir §“Secrets par instance”.
Rotation d’un secret (procédure canonique)
# 1. Éditer la source unique
$EDITOR .secrets/master.env # ex: changer HF_TOKEN
# 2. Propager vers tous les .env consommateurs
python scripts/secrets/render_envs.py
# 3. Redémarrer les containers impactés (OBLIGATOIRE pour ComfyUI-Login,
# voir §"Règle du restart ComfyUI" ci-dessous)
docker compose -f docker-configurations/services/<svc>/docker-compose.yml restartLe render est idempotent : re-lancer ne change rien si déjà synchronisé.
Audit / détection de drift
python scripts/secrets/render_envs.py --check
# exit 0 = tous les .env synchronisés avec master
# exit 1 = drift détéré (un .env a été édité à la main), affiche chaque écart--check compare chaque clé secrète de chaque .env à master.env. À lancer après toute édition manuelle d’un .env, ou périodiquement. Note : c’est un outil local (les .env sont gitignored, donc absents d’un checkout CI frais) — le scanner d’inline-literals côté code committé reste gitleaks CI (cf secrets-hygiene.md).
Inventaire des secrets centralisés (master.env)
| Catégorie | Clés | Consommateurs | Sensible rotation |
|---|---|---|---|
| Hugging Face (aliasé) | HF_TOKEN = HUGGINGFACE_TOKEN |
5 services + notebooks | Oui (gated/quotas) |
| LLM APIs payantes | OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY |
notebooks | Oui (facturation) |
| Hubs / git | CIVITAI_TOKEN, GITHUB_TOKEN = GITHUB_ACCESS_TOKEN |
services + notebooks | Oui |
| Client API keys (server↔︎client) | WHISPER_API_KEY, VLLM_API_KEY, TTS_API_KEY, QWEN_ASR_API_KEY, MUSICGEN_API_KEY, DEMUCS_API_KEY, FUNASR_API_KEY |
1 service + notebooks | Moyen |
| Qdrant vector DB (client) | QDRANT_API_KEY |
notebooks RAG / SemanticKernel / Argument_Analysis | Moyen (flip serveur = op inter-repo roo-extensions) |
| Tokens client ComfyUI | COMFYUI_VIDEO_TOKEN, COMFYUI_API_TOKEN = COMFYUI_AUTH_TOKEN |
notebooks → services ComfyUI | Moyen |
| Session | SECRET_KEY |
1 service | Oui |
Alias : HF_TOKEN/HUGGINGFACE_TOKEN, GITHUB_TOKEN/GITHUB_ACCESS_TOKEN et COMFYUI_API_TOKEN/COMFYUI_AUTH_TOKEN désignent le MÊME secret sous deux noms (services vs notebooks). Le bootstrap vérifie leur cohérence (abort si divergence).
L’alias ne fabrique PAS le nom manquant — les deux doivent être écrits dans master.env. ALIASES (render_envs.py) déclare la paire ; il ne dérive pas HF_TOKEN d’un HUGGINGFACE_TOKEN présent seul. Un master.env qui ne porte qu’un des deux noms laisse l’autre dans la liste declared SECRET keys are absent from master.env (left untouched in services) de --check, et aucun consommateur ne le reçoit — alors que le token existe et est valide. Mesuré le 2026-08-15 : HUGGINGFACE_TOKEN présent + HF_TOKEN absent, pour 655 sites de code lisant HF_TOKEN contre 144 lisant HUGGINGFACE_TOKEN. Remède : écrire les deux lignes dans master.env, puis render_envs.py.
sync() rafraîchit, il n’insère pas. Une clé absente d’un .env cible n’y est pas ajoutée par le render : seules les clés déjà déclarées y sont réécrites depuis master (le gap-fill n’existe que pour un service sans .env, à partir des ${KEY} de sa compose). Pour un .env côté notebooks (pas de compose), déclarer la ligne vide CLE= puis lancer le render.
Un 403 sur un repo gated n’est pas un défaut de token. Discriminer avant d’escalader — repo non-gated avec token → 206 (le token lit) ; repo gated sans token → 401 ; repo gated avec token → 403 = authentifié mais non autorisé, c’est-à-dire licence non acceptée par le compte porteur ou scope « read gated repos » absent du token fine-grained. Les deux se règlent sur huggingface.co, pas dans master.env.
ComfyUI-Login (comfyui-qwen) — source unique du credential API (#14382)
Le middleware ComfyUI-Login valide le bearer littéralement contre le fichier bind-mounté .secrets/qwen-api-user.token (60 caractères, forme $2b$ bcrypt — le hash est le token partagé). Avant #14382, le compose interpolait des noms que render_envs.py ne gérait pas (COMFYUI_BEARER_TOKEN, COMFYUI_RAW_TOKEN) : sur un .env rendu par le seul render, ils résolvent vide (mesure #14382) ; là où auth_manager.py les avait écrits, ce sont des copies non gérées qui dérivent à la prochaine rotation (pattern incident #6901). Le sidecar idle-monitor recevait en outre COMFYUI_RAW_TOKEN (le mot de passe formulaire, pas le bearer) → ses polls d’activité prenaient 401.
Le câblage depuis #14382 ne fait plus dériver — et ne passe jamais par l’interpolation d’env du compose : docker compose interprète les $ des valeurs (le bcrypt $2b$12$Iv… devient un token tronqué 60c → 57c, mesuré en conteneur). Le credential passe par le fichier (les fichiers bind-mountés ne sont pas interpolés) :
| Consommateur | Voie |
|---|---|
middleware ComfyUI-Login (entrypoint.sh) |
fichier bind-mounté .secrets/qwen-api-user.token (déjà le chemin privilégié) |
workspace/install_comfyui.sh → ComfyUI-Login/PASSWORD |
cp du fichier monté (l’echo de l’env compose écrivait un token tronqué) |
sidecar idle-monitor (poll /system_stats) |
fichier bind-mounté /secrets/qwen-api-user.token, lu en fallback par comfyui_idle_monitor.py |
notebooks GenAI (os.getenv("COMFYUI_AUTH_TOKEN") or os.getenv("COMFYUI_API_TOKEN"), flip #16) |
GenAI/.env, les deux noms gérés master (alias COMFYUI_AUTH_TOKEN = COMFYUI_API_TOKEN) |
Rotation du credential comfyui-qwen : éditer COMFYUI_API_TOKEN et COMFYUI_AUTH_TOKEN dans master.env (même valeur), régénérer .secrets/qwen-api-user.token avec cette valeur, render_envs.py, puis recréer le container (docker compose up -d — un restart ne relit ni les montages ni l’env d’interpolation ; cf règle du restart ci-dessous pour COMFYUI_PASSWORD).
COMFYUI_RAW_TOKEN (le mot de passe en clair avant hash, utile seulement au login formulaire COMFYUI_USERNAME/COMFYUI_PASSWORD) ne transite plus par aucun .env géré — la forme brute ne doit pas vivre dans les .env (seule la forme hashée y circule). auth_manager.py n’écrit plus COMFYUI_BEARER_TOKEN/COMFYUI_RAW_TOKEN dans les .env.
COMFYUI_BEARER_TOKEN est un nom retiré (pré-#14382). Il ne vit plus que dans les archives (docs/archive/, scripts/genai-stack/_archive/), les tests de tolérance legacy (test_genai_stack_pure.py préserve sans réécrire ; verify_running_containers.py tolère les services déployés avant le recâblage fichier) et les commentaires historiques. Toute occurrence vivante qui le prescrit comme nom requis est un bug — le canon est COMFYUI_API_TOKEN (alias notebooks COMFYUI_AUTH_TOKEN), sweep #16647.
Qdrant — convention client vs serveur (cross-repo)
Qdrant expose la même clé API sous deux noms selon le côté :
- Serveur Qdrant —
QDRANT__SERVICE__API_KEY(double underscore, conventionconfig.yamlQdrant). Vit dans la composeroo-extensions(autre repo). Non centralisé ici : le flip de valeur est une op inter-repo manuelle. - Client (notebooks CoursIA) —
QDRANT_API_KEY(simple underscore, convention des clientsqdrant-client). Vit dansMyIA.AI.Notebooks/GenAI/.env. Centralisé dansSECRET_KEYS(render_envs.py).
Les deux noms doivent porter la même valeur (sinon 401 côté client). Centraliser le côté client CoursIA permet aux notebooks de suivre la rotation sans action manuelle : edit master.env → render_envs.py propage vers GenAI/.env. Le flip de la valeur côté serveur (rotation effective, Qdrant redémarre avec la nouvelle clé) reste une opération manuelle inter-repo sur roo-extensions — tracker séparément, cf SECURITY.md.
WHISPER_API_KEY — convention client ↔︎ service + propagation cross-repos
WHISPER_API_KEY est un bearer token partagé entre le service whisper-api (po-2023, port 8190) et ses consommateurs — notebooks CoursIA ET plusieurs workspaces cross-repo. Comme Qdrant, sa rotation a deux volets : CoursIA (automatisé) et cross-repo (manuel).
Côté CoursIA (automatisé via render_envs.py) : WHISPER_API_KEY est dans SECRET_KEYS et whisper-api/.env est un TARGET_ENV (glob services/*/.env). La rotation suit le runbook canonique ci-dessus — edit master.env → render_envs.py → docker compose restart whisper-api. Le compose injecte API_KEY=${WHISPER_API_KEY:-} ; l’auth est active (vérifié 2026-07-05 : POST /v1/audio/transcriptions sans bearer → 401).
Côté cross-repo (propagation manuelle) : le même token est consommé par des workspaces hors CoursIA. Après rotation CoursIA, propager la nouvelle valeur à chaque consommateur (RooSync privé ou édition du .env/config local du workspace) :
| Workspace | Usage | Méthode de propagation |
|---|---|---|
roo-extensions |
sk-agent MCP speech-to-text | config MCP / .env local |
myia-open-webui |
Web UI STT | .env / config |
hermes-agent (repo public) |
pipeline ASR | ⚠️ JAMAIS dans le repo public — env var runtime ou RooSync privé (cf incident 2026-05-16 ci-dessous) |
nanoclaw |
pipeline ASR | config locale (cf nanoclaw-asr-dependency) |
Cette liste reflète les consommateurs connus à la dernière rotation (2026-05). Vérifier le déploiement courant avant de rotater — un nouveau consommateur aurait pu être ajouté.
Vérification post-rotation (sur po-2023, après restart du container) :
# Token manquant → 401 (auth active, requête refusée)
curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8190/v1/audio/transcriptions
# Token valide → 500 (auth passe, mais pas de fichier audio = attendu)
curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer <nouveau_token>" -X POST http://localhost:8190/v1/audio/transcriptionsIncident fondateur (hermes-agent, 2026-05-16) : un WHISPER_API_KEY a fuité via un .env.example commité dans le repo public jsboige/hermes-agent. Résolution : token rotaté, container restarté, fuite corrigée côté hermes-agent. Règle dure : dans tout repo public, utiliser des placeholders (YOUR_API_KEY_HERE), jamais la valeur réelle — même dans un .env.example. Cet incident motive le ⚠️ sur la ligne hermes-agent ci-dessus.
Secrets par instance (NON centralisés — config service)
Ces secrets sont légitimement différents par instance (un mot de passe par container). Ils restent dans le .env de leur service et ne sont jamais collapsés :
| Secret | Instances | Pourquoi par-instance |
|---|---|---|
COMFYUI_PASSWORD, COMFYUI_USERNAME |
comfyui-qwen | Chaque ComfyUI a son propre login |
FORGE_PASSWORD, FORGE_USER |
forge-turbo, sd-forge-main | Chaque Forge a son propre login |
WHISPER_PASSWORD/WHISPER_USER, WHISPER_WEBUI_PASSWORD/WHISPER_WEBUI_USER |
whisper-api, whisper-webui | Logins par instance |
Leur prévention du drift = la règle du restart (ci-dessous) + le self-check entrypoint, PAS la centralisation.
Exception #10985 (décision user 2026-08-20) — comfyui-video : le mot de passe de comfyui-video est centralisé dans master.env sous le nom instance-scopé COMFYUI_VIDEO_PASSWORD (propagé par render vers comfyui-video/.env et le .env client GenAI). Le compose mappe cette clé vers l’env conteneur COMFYUI_PASSWORD (nom lu par entrypoint.sh et ComfyUI-Login, inchangé). Le nom homonyme nu COMFYUI_PASSWORD reste interdit dans SECRET_KEYS : comfyui-qwen porte une valeur différente légitime (conflit dur au bootstrap). Motivation : rotation trousseau partagé po-2023 ↔︎ ai-01, aucun mot de passe en clair dans ce qui remonte sur github.com.
Règle du restart ComfyUI-Login (le piège du drift)
ComfyUI-Login stocke un hash bcrypt dans workspace/login/PASSWORD (bind-mount persistant). L’entrypoint.sh régénère ce hash depuis COMFYUI_PASSWORD (env) à chaque démarrage du container — mais pas pendant qu’il tourne.
Conséquence : si tu changes COMFYUI_PASSWORD dans le .env sans restart, le container garde un hash périmé jusqu’au prochain restart. Un client qui s’authentifie avec le nouveau mot de passe obtient un 401 — exactement le symptôme qui a fait croire (à tort) à une “clé perdue”.
Règle : après tout changement de COMFYUI_PASSWORD (ou COMFYUI_*_TOKEN), docker compose restart le container concerné. Le render affiche un rappel.
Self-check entrypoint : après écriture du hash, l’entrypoint vérifie bcrypt.checkpw(COMFYUI_PASSWORD, hash) et logge la confirmation — donc les logs de démarrage prouvent que le hash du container courant matche le .env au startup.
Incident fondateur (juin 2026)
Diagnostic erroné “drift bcrypt / plaintext perdu” sur comfyui-video → demande user de reset. Vérification re-faite : bcrypt.checkpw(.env_password, hash) == True — la clé n’était JAMAIS perdue. Le container avait simplement été vérifié avant son restart (hash périmé en RAM), puis restarté (hash régénéré depuis le .env courant). Leçon G.1/G.9 : un verdict “clé perdue” doit être vérifié après restart, pas sur un container au hash potentiellement périmé. Cet épisode a motivé la centralisation ci-dessus.
À faire (phase 2, optionnel)
- Brancher
render_envs.py --checken pre-commit local (hookpre-commit, pas CI —.envabsents du checkout CI). - Étendre
master.envaux tokens cross-repos (roo-extensions, hermes-agent) via un master partagé si besoin.
Voir aussi
- .claude/rules/secrets-hygiene.md — anti-patterns inline-literals, règle HARD content-based.
- docs/reference/secrets-and-coord-detail.md — incidents + postmortem responsable.
- scripts/secrets/render_envs.py — l’outil (
--bootstrap/ sync /--check).