docs: add POC specs, handoffs, and research notes

This commit is contained in:
Dom
2026-06-02 16:28:34 +02:00
parent 18ed6cb751
commit f2e9aac6b7
86 changed files with 27615 additions and 25 deletions

View File

@@ -0,0 +1,729 @@
# AUDIT — Migration Token Global → Token Par-Poste
## rpa_vision_v3 — POC Clinique Wallerstein
**Status** : DRAFT lecture seule — audit factuel — pas encore appliqué
**Date** : 2026-06-01
**Scope** : Identification des modifications pour passer d'un token API global (partagé entre tous les postes) à un token unique par-poste permettant la révocation chirurgicale indépendante
**Contexte** : POC sur ≤5 TIM pendant plusieurs mois ; passage de 3 mois (doc initiale) à durée longue → risque amplifié de compromission d'un poste
---
## 1. Source du Token Actuel — Facteurs Identifiés
### 1.1 Génération et Stockage Côté Serveur
**Fichier** : `agent_v0/server_v1/api_stream.py` (lignes 285-363)
| Point | Facteur |
|-------|---------|
| **Variable env** | `RPA_API_TOKEN` (obligatoire en production) |
| **Mode génération** | Lecture depuis l'env OBLIGATOIRE (`os.getenv("RPA_API_TOKEN", "").strip()`) |
| **Tolérance dev** | Génération aléatoire via `secrets.token_hex(32)` si `RPA_AUTH_DISABLED=true` |
| **Fail-closed** | Production : token manquant → `sys.exit(1)` immédiat, pas de génération silencieuse |
| **Scope du token** | **GLOBAL** : un seul token pour tous les postes clients (tous les TIM partagent la même valeur) |
| **Type de valeur** | Hex string de 32 caractères (e.g. `a1b2c3d4e5f6...`) |
| **Lifecycle** | Aucune rotation built-in ; révocation → rotation manuelle de la var env + redémarrage du serveur |
### 1.2 Distinction Admin/ReadOnly
**Constat** : **N'existe pas actuellement.**
- Un seul niveau de droit : droits R/W complets
- Tous les postes ont capacité à : capturer screenshot, invoquer VLM, lire/exécuter workflows
- Aucune notion de scope ou permission granulaire par token
### 1.3 Lecture au Démarrage du Serveur
**Code** (api_stream.py:301-328) :
```python
_API_TOKEN_ENV = os.environ.get("RPA_API_TOKEN", "").strip()
if _AUTH_DISABLED:
API_TOKEN = _API_TOKEN_ENV or secrets.token_hex(32)
elif not _API_TOKEN_ENV:
logger.critical("[SÉCURITÉ] FATAL — RPA_API_TOKEN est absent ou vide. ...")
sys.exit(1)
else:
API_TOKEN = _API_TOKEN_ENV
```
**Impact** : Token chargé une seule fois au boot, inchangé pendant toute la session du serveur.
---
## 2. Fabrication du ZIP Installeur Léa — Flux Token
### 2.1 Pipeline de Téléchargement du ZIP
**Endpoint** : `web_dashboard/app.py``POST /api/fleet/download/<machine_id>` (ligne 2156)
**Flux** :
```
1. Dashboard Frontend (ui) demande au Dashboard Backend (5001)
2. Backend lit la liste des agents enregistrés (AgentRegistry SQLite)
3. Requête POST vers serveur streaming (5005) : /api/v1/agents/fleet
4. Streaming renvoie liste agents (enrolled_agents table)
5. Backend construit config.txt avec :
- RPA_SERVER_URL (toujours conforme, finit par /api/v1)
- RPA_API_TOKEN = valeur globale unique (tous les postes)
- RPA_MACHINE_ID = fourni par l'UI
- RPA_USER_LABEL = nom du collaborateur
```
### 2.2 Source du Token Injecté dans le ZIP
**Fichier** : `web_dashboard/app.py` (ligne 2183)
```python
api_token = os.environ.get('RPA_API_TOKEN', '') # <- MÊME VALEUR pour tous les postes
custom_config = _build_custom_config(machine_id, user_name, api_token)
```
**Constat** :
- Le token global de l'environnement du Dashboard (5001) est utilisé
- **Tous les ZIPs téléchargés contiennent le même token** (pas de diversification par poste)
- Aucune génération de token unique lors du POST /download
### 2.3 Machine ID — Source et Validation
**Provenance du machine_id** :
- Côté **client Windows** (Léa.bat / install.bat) : Inno Setup génère un UUID via la page custom d'enrollment
- Côté **serveur** : Enrôlement possible sans vérification du machine_id source (endpoint `/api/v1/agents/enroll` accepte n'importe quel machine_id du client)
**Validation** :
- Pas de vérification cryptographique du machine_id (e.g. pas de signature pour prouver l'authenticité)
- Le machine_id est traité comme **identifiant de confiance** après enrôlement (logging, last_seen_at)
### 2.4 Endpoint d'Enrôlement — État Actuel
**Endpoint** : `agent_v0/server_v1/api_stream.py``POST /api/v1/agents/enroll` (ligne 6638)
| Aspect | État Actuel |
|--------|-------------|
| **Qui génère le token ?** | Dashboard Backend (lit env `RPA_API_TOKEN`) |
| **Le client enrôlé reçoit quoi ?** | Le token global dans la réponse `/api/v1/agents/enroll` (ligne 6696) |
| **Existe-t-il un endpoint qui génère token unique ?** | Non. Phase 2 signalée dans le commentaire (ligne 6647) |
| **Stockage des tokens par-poste ?** | Non. Table `enrolled_agents` enregistre le poste mais pas le token |
**Code** (api_stream.py:6688-6698) :
```python
return {
"status": "enrolled",
"created": result["created"],
"reactivated": result["reactivated"],
"machine_id": machine_id,
# Phase 1 : on renvoie le token global pour que le client puisse
# verifier qu'il est bien aligne avec le serveur. Phase 2 pourra
# emettre un token par poste (issued_token != API_TOKEN global).
"api_token": API_TOKEN, # <- GLOBAL
"agent": agent,
}
```
---
## 3. Validation du Token Côté Serveur
### 3.1 Middleware de Vérification
**Fichier** : `agent_v0/server_v1/api_stream.py` (ligne 351)
```python
async def _verify_token(request: Request):
"""Middleware de vérification du token API Bearer."""
if _AUTH_DISABLED:
return
if request.url.path in _PUBLIC_PATHS:
return
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer ") or auth[7:] != API_TOKEN:
raise HTTPException(status_code=401, detail="Token API invalide")
```
**Constat** :
- **Comparaison à une constante** unique (pas de lookup table)
- Tous les endpoints protégés (sauf 5 publics) nécessitent ce token exact
- Le token est envoyé dans chaque requête HTTP : `Authorization: Bearer <token>`
### 3.2 Granularité des Droits
**Constat** : **Un seul niveau : R/W complet**
- Pas de distinction admin/user/readonly
- Pas de scope granulaire (e.g. "capture seulement" vs "exécution workflow")
- Tous les agents ayant le bon token peuvent accéder à toutes les fonctions
### 3.3 Machine ID et Audit
**Machine_id dans les requêtes** :
Le client (Léa) envoie le machine_id dans les headers ou dans le body de certaines requêtes. Côté serveur :
- `audit_trail.record(AuditEntry(..., machine_id=client_provided_machine_id, ...))` (enregistré, pas vérifié)
- `agent_registry.touch_last_seen(machine_id)` (mise à jour timestamp)
**Risque** : Aucune vérification que le machine_id envoyé par le client correspond au token utilisé. Un client pourrait usurper l'identité d'une autre machine en envoyant un autre machine_id + le token global.
---
## 4. Points d'Appel Côté Agent Windows
### 4.1 Lecture de config.txt au Démarrage
**Fichier** : `agent_v0/agent_v1/config.py` (ligne 57)
```python
API_TOKEN = os.environ.get("RPA_API_TOKEN", "")
```
Le token vient de trois sources (ordre de priorité) :
1. Variable env `RPA_API_TOKEN` (posée par Lea.bat après parsing de config.txt)
2. Fichier `config.txt` dans le ZIP (contient le token global)
3. Défaut : chaîne vide (agent lancé sans token)
**Fichier** : `deploy/lea_package/Lea.bat` (ligne 40-43)
```batch
if exist "config.txt" (
for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do (
if not "%%a"=="" if not "%%b"=="" set "%%a=%%b"
)
)
```
Lea.bat parse config.txt ligne par ligne et pose les variables d'environnement (y compris `RPA_API_TOKEN`).
### 4.2 Envoi du Token dans les Requêtes
**Streaming WebSocket** : `agent_v0/agent_v1/network/streamer.py` (ligne ~80-90)
```python
from ..config import API_TOKEN, STREAMING_ENDPOINT
# Dans la fonction d'auth :
if API_TOKEN:
return {"Authorization": f"Bearer {API_TOKEN}"}
```
**Requêtes REST (executor)** : `agent_v0/agent_v1/core/executor.py`
```python
if API_TOKEN:
headers["Authorization"] = f"Bearer {API_TOKEN}"
```
**Constat** :
- Token présent dans **chaque** requête (REST + WebSocket streaming)
- Format standard Bearer : `Authorization: Bearer <token_value>`
- Aucune logique de refresh ou renouvellement
### 4.3 Renouvellement / Refresh du Token
**Constat** : **N'existe pas.**
- Token lu une seule fois au démarrage de l'agent
- Aucun mécanisme pour redemander un token au serveur sans redémarrer l'agent
- Révocation d'un token global = tous les agents existants deviennent invalides immédiatement (pas de transition gracieuse)
---
## 5. Architecture Cible : Token Par-Poste
### 5.1 Schéma de Stockage Côté Serveur
**Nouvelle table SQLite** dans `data/databases/rpa_data.db` :
```sql
CREATE TABLE IF NOT EXISTS postes_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL, -- SHA256(token)
token_readable TEXT NOT NULL, -- token lisible, jamais loggé
status TEXT DEFAULT 'active', -- 'active', 'revoked'
created_at TEXT NOT NULL, -- ISO 8601 UTC
revoked_at TEXT, -- ISO 8601 UTC si révoqué
rotated_at TEXT, -- ISO 8601 UTC si régénéré
last_seen_at TEXT, -- Dernière utilisation
rotation_reason TEXT -- 'manual_revoke', 'rotation_request', etc.
);
CREATE INDEX idx_postes_tokens_machine_id ON postes_tokens(machine_id);
CREATE INDEX idx_postes_tokens_status ON postes_tokens(status);
```
**Co-habitation** : Table dans la même DB que `enrolled_agents` (même `data/databases/rpa_data.db`).
### 5.2 Endpoint d'Enrôlement Amélioré
**Route modifiée** : `POST /api/v1/agents/register` (nouvelle, plutôt que d'écraser `/agents/enroll`)
```python
@app.post("/api/v1/agents/register", status_code=201)
async def agents_register(request: AgentRegisterRequest):
"""
Enrôlement du poste + génération du token unique.
Comportement :
- Enregistre le poste (ou le réactive) dans enrolled_agents
- Génère un token unique via secrets.token_urlsafe(32)
- Stocke le hash SHA256 du token dans postes_tokens
- Retourne le token UNE SEULE FOIS dans la réponse
Client doit sauvegarder ce token dans config.txt immédiatement.
Aucune autre route ne renvoie le token en clair.
"""
machine_id = (request.machine_id or "").strip()
if not machine_id:
raise HTTPException(status_code=400, detail="machine_id obligatoire")
# 1. Enregistrer le poste (ou le réactiver)
try:
result = agent_registry.enroll(machine_id=machine_id, ...)
except AgentAlreadyEnrolledError:
# Poste déjà actif : générer un nouveau token pour rotation
pass
# 2. Générer token unique
token_value = secrets.token_urlsafe(32) # e.g. "aB1_cD2-eF3...XyZ"
token_hash = hashlib.sha256(token_value.encode()).hexdigest()
# 3. Insérer ou mettre à jour dans postes_tokens
postes_registry.create_or_rotate(
machine_id=machine_id,
token_hash=token_hash,
token_readable=token_value, # Stocké temporairement
rotation_reason="enrollment"
)
# 4. Retourner le token UNE SEULE FOIS
return {
"status": "registered",
"machine_id": machine_id,
"token": token_value, # <- À SAUVEGARDER IMMÉDIATEMENT
"expires_in": "never", # Sans expiration, mais révocable à tout moment
"agent": {...}
}
```
### 5.3 Modification du Middleware d'Auth
**Fichier** : `agent_v0/server_v1/api_stream.py` (remplacer `_verify_token`)
```python
async def _verify_token(request: Request):
"""Middleware de vérification du token par-poste.
Lookup du token dans postes_tokens au lieu de comparaison constante.
Cache mémoire pour éviter hammer SQLite à chaque requête.
"""
if _AUTH_DISABLED:
return
if request.url.path in _PUBLIC_PATHS:
return
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Token invalide")
token_value = auth[7:]
token_hash = hashlib.sha256(token_value.encode()).hexdigest()
# 1. Chercher dans le cache (dict en mémoire)
if token_hash in _token_cache:
cached = _token_cache[token_hash]
if cached["status"] != "active":
raise HTTPException(status_code=401, detail="Token révoqué")
cached["last_used"] = time.time()
request.scope["machine_id"] = cached["machine_id"] # Injecter pour l'audit
return
# 2. Chercher en DB
row = postes_registry.get_by_hash(token_hash)
if row is None or row["status"] != "active":
raise HTTPException(status_code=401, detail="Token invalide")
# 3. Mettre en cache
_token_cache[token_hash] = {
"machine_id": row["machine_id"],
"status": row["status"],
"last_used": time.time(),
}
# Limiter la taille du cache (LRU, max 1000 tokens)
if len(_token_cache) > 1000:
oldest = min(_token_cache.items(), key=lambda x: x[1]["last_used"])
del _token_cache[oldest[0]]
# Injecter machine_id pour l'audit
request.scope["machine_id"] = row["machine_id"]
return
```
### 5.4 Endpoint de Révocation
**Route** : `POST /api/v1/postes/<machine_id>/revoke` (sécurisé par auth Bearer)
```python
@app.post("/api/v1/postes/{machine_id}/revoke")
async def revoke_poste(machine_id: str):
"""
Révoque le token du poste (soft delete, audit conservé).
Dashboard ou administrateur : révoque un poste compromis.
"""
# Vérifier que le token Bearer utilisé a le droit de révoquer
# (optionnel : admin-only, ou même machine_id peut révoquer son propre token)
row = postes_registry.revoke(machine_id=machine_id, reason="admin_revoke")
if row is None:
raise HTTPException(status_code=404, detail="Machine not found")
# Invalider le cache
if row["token_hash"] in _token_cache:
del _token_cache[row["token_hash"]]
logger.info(f"[SECURITY] Poste révoqué : {machine_id}")
return {"status": "revoked", "machine_id": machine_id}
```
### 5.5 Endpoint de Régénération
**Route** : `POST /api/v1/postes/<machine_id>/rotate` (pour l'utilisateur du poste)
```python
@app.post("/api/v1/postes/{machine_id}/rotate")
async def rotate_poste_token(machine_id: str):
"""
L'utilisateur du poste demande un nouveau token (le vieil est invalidé).
Retourne le nouveau token (à injecter dans config.txt).
"""
# Vérifier que le token actuel appartient à ce machine_id
# (via injection dans request.scope par le middleware)
new_token = secrets.token_urlsafe(32)
new_hash = hashlib.sha256(new_token.encode()).hexdigest()
postes_registry.rotate(
machine_id=machine_id,
new_token_hash=new_hash,
rotation_reason="user_request"
)
# Invalider l'ancien token du cache
old_row = postes_registry.get_by_machine_id(machine_id)
if old_row and old_row["token_hash"] in _token_cache:
del _token_cache[old_row["token_hash"]]
return {
"status": "rotated",
"machine_id": machine_id,
"new_token": new_token,
"hint": "Mettez à jour config.txt avec ce nouveau token"
}
```
### 5.6 UI Dashboard — Onglet Postes
**Nouvelle page** : `/templates/fleet_management.html`
Fonctionnalités :
- Liste des postes actifs (machine_id, user_name, last_seen_at, enrolled_at)
- Liste des postes révoqués (avec date révocation + raison)
- Boutons par poste :
- **Révoquer** : POST `/api/v1/postes/{machine_id}/revoke` → impossibilité pour le poste de se connecter
- **Régénérer token** : POST `/api/v1/postes/{machine_id}/rotate` → nouveau token à faire entrer sur le poste
- **Afficher détails** : historique de révocation/rotation
- Filtres : statut (actif/révoqué), plage de dates, collaborateur
---
## 6. Chiffrage Qualitatif de la Migration
### 6.1 Fichiers à Modifier
| Fichier | Modification | Effort | Risque |
|---------|--------------|--------|--------|
| `agent_v0/server_v1/api_stream.py` | Remplacer `_verify_token`, ajouter `_token_cache`, endpoints `/api/v1/postes/*/revoke` et `/rotate` | **Moyen** (~200-250 lignes) | Moyen : tous les endpoints dépendent du middleware |
| `agent_v0/server_v1/agent_registry.py` | Ajouter classe `PostesTokenRegistry` (CRUD postes_tokens table) | **Moyen** (~150-200 lignes) | Faible : isolé, pas d'impact existant |
| `agent_v0/server_v1/` (nouveau) | `postes_registry.py` (classe PostesTokenRegistry) | **Moyen** (~150-200 lignes) | Faible : nouveau module |
| `web_dashboard/app.py` | Remplacer `_build_custom_config` + endpoint `/api/fleet/download` pour générer token unique | **Moyen** (~100-150 lignes) | Moyen : impact sur téléchargement ZIP |
| `web_dashboard/templates/` | Ajouter `fleet_management.html` (onglet postes) | **Moyen** (~300-400 lignes HTML/JS) | Faible : interface nouvelle, pas de régression |
| `agent_v0/agent_v1/config.py` | Pas de modification (lecture env inchangée) | **Aucun** | Aucun |
| `deploy/installer/Lea.iss` | Pas de modification (Inno Setup pose la var env) | **Aucun** | Aucun |
**Total effort** : ~1000-1300 lignes de code nouveau / modifié
### 6.2 Migration Progressive en 3 Étapes
#### Étape A : Fondations (1-2 jours)
**Objectif** : Ajouter la table + le registre, mais le serveur accepte les 2 modes.
1. Créer table `postes_tokens` + index dans `data/databases/rpa_data.db`
2. Implémenter classe `PostesTokenRegistry` (CRUD simple)
3. Modifier `_verify_token` pour :
- D'abord chercher le token en par-poste (lookup table)
- Si non trouvé, faire un fallback sur le mode global (API_TOKEN)
4. Logger chaque validation en clair : `[AUTH] Token trouvé : par-poste | global`
**Pre-requis** : Aucun
**Durée estimée** : 1-2 jours
**Point de non-retour** : Non (le fallback permet de revenir en arrière)
**Impact utilisateurs** : Nul (transparent, les agents existants continuent avec le global)
**Code exemple (middleware)**:
```python
async def _verify_token(request: Request):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401)
token_value = auth[7:]
token_hash = hashlib.sha256(token_value.encode()).hexdigest()
# Mode 1 : Par-poste
row = postes_registry.get_by_hash(token_hash)
if row and row["status"] == "active":
logger.info(f"[AUTH] Token par-poste validé : {row['machine_id']}")
request.scope["machine_id"] = row["machine_id"]
return
# Mode 2 : Global (fallback, phase 1)
if token_value == API_TOKEN:
logger.info(f"[AUTH] Token global validé (fallback phase 1)")
return
raise HTTPException(status_code=401, detail="Token invalide")
```
#### Étape B : Redéploiement sur Postes Wallerstein (3-5 jours)
**Objectif** : Migrer les 5 postes du POC en par-poste (ZIPs avec tokens uniques).
1. Nouvelle version du Dashboard : endpoint `/api/v1/agents/register` qui génère tokens uniques
2. Modifier `/api/fleet/download/<machine_id>` pour injecter le token unique (pas le global)
3. Sur chaque poste Wallerstein :
- Télécharger le nouveau ZIP via le Dashboard
- Extraire et remplacer config.txt
- Redémarrer Léa
**Pre-requis** : Étape A complète et testée
**Durée estimée** : 3-5 jours (déploiement sur le terrain + tests)
**Point de non-retour** : OUI — une fois que tous les postes ont le par-poste, l'admin peut supprimer API_TOKEN global
**Impact utilisateurs** : Très faible (ZIPs pré-générés, transparents pour les utilisateurs)
**Checklist de déploiement** :
- [ ] Tester endpoint `/api/v1/agents/register` en lab
- [ ] Générer 5 ZIPs avec tokens uniques (une clé par poste)
- [ ] Vérifier que config.txt contient le bon token pour chaque machine_id
- [ ] Déployer sur poste 1, vérifier dernière utilisation dans Dashboard
- [ ] Idem postes 2-5
- [ ] Vérifier historique audit : chaque poste tracé séparément
#### Étape C : Durcissement — Suppression du Global (1 jour)
**Objectif** : Désactiver le mode global, garantir que seule l'authentification par-poste fonctionne.
1. Retirer le fallback global de `_verify_token`
2. Supprimer variable env `RPA_API_TOKEN` du bootstrap
3. Logger un FATAL si la variable est trouvée (debug)
4. Mettre à jour la doc : "Mode global dépréciéé depuis 2026-06-XX"
**Pre-requis** : Tous les postes en par-poste (Étape B complète)
**Durée estimée** : 1 jour
**Point de non-retour** : OUI — après ce point, les anciens agents (mode global) ne peuvent plus se connecter
**Impact utilisateurs** : ÉLEVÉ si Étape B incomplète (agents bloqués)
---
## 7. Risques Non Couverts Par la Migration
### 7.1 Vol Physique du PC TIM avec config.txt en Clair
**Risque** : Token stocké en clair dans le fichier `config.txt` (Windows, pas chiffré par défaut)
**Mitigations possibles** :
- DPAPI Windows : Chiffrer config.txt via `CryptProtectData` (nécessite clé utilisateur Windows)
- Stockage sécurisé : Léa peut demander token à la première utilisation (sans le persister)
- JWT court + refresh : Token courte durée de vie (~30 min) + refresh token distinct (mais complexe)
- Jeton hardware : Clé USB ou smartcard (hors scope POC)
**Recommandation** : Pour Wallerstein, utiliser DPAPI Windows (effort faible, sécurité augmentée).
### 7.2 Replay Attacks sur le Streaming WebSocket
**Risque** : Sans TLS strict (HTTPS/WSS), un attaquant en MITM peut rejouer les requêtes vidées du token Bearer.
**Mitigations actuelles** :
- Reverse proxy NPM (déjà en place) expose lea.labs.laurinebazin.design en HTTPS
- WSS (WebSocket Secure) utilisé en production (non en lab)
**Recommandation** : Vérifier que tous les endpoints streaming utilisent WSS en production (config NPM).
### 7.3 Token Enregistré dans les Logs
**Risque** : Si Flask ou Uvicorn log les en-têtes HTTP en DEBUG, le token Bearer s'affiche en clair.
**Prévention** :
- Mode production (défaut) : `docs_url=None, redoc_url=None` (api_stream.py:469-471)
- Vérifier que les logs ne contiennent jamais `Authorization: Bearer ...`
**Commande de vérification** :
```bash
grep -r "Authorization.*Bearer" /path/to/logs/ # Ne doit retourner rien
grep -r "RPA_API_TOKEN" /path/to/logs/ # Ne doit retourner rien
```
### 7.4 Usurpation d'Identité : Machine_ID
**Risque** : Actuellement, aucune vérification que le machine_id envoyé par le client correspond au token utilisé.
**Scénario** : Client A possède token de poste A. Poste A envoie requête avec `machine_id=poste_B` → audit imputé à poste B.
**Mitigation dans l'architecture cible** :
- Dès que le token est validé, extraire automatiquement le machine_id depuis la table postes_tokens
- Rejecter toute tentative du client d'envoyer un machine_id différent
```python
async def _verify_token(request: Request):
# ... validation token ...
row = postes_registry.get_by_hash(token_hash)
# Injecter l'identité vraie dans la requête
request.scope["machine_id"] = row["machine_id"] # <- Vrai machine_id
request.scope["user_id"] = row["user_id"] # <- Vrai user_id
# Ne pas faire confiance au client pour l'identité
```
### 7.5 Absence de Rotation Forcée
**Risque** : Aucune rotation obligatoire (ex. tous les 90 jours). Un token compromis et non détecté reste valide indéfiniment.
**Mitigation pour Wallerstein** :
- Ajouter `last_rotation_at` et `rotation_period_days` à la table postes_tokens
- Endpoint `/api/v1/postes/{machine_id}/status` retourne un warning si rotation > 90 jours
- Dashboard affiche visuel "Token à renouveler" (jaune : 60j, rouge : >90j)
**Effort** : Moyen (~50-100 lignes)
---
## 8. Verdict et Recommandations
### Effort Global
**Catégorie** : **MOYEN** (4-8 jours-homme, 1000-1300 lignes, pas d'architecte externe requis)
- Fondations (Étape A) : 1-2 jours
- Déploiement terrain (Étape B) : 3-5 jours (nécessite accès aux postes Wallerstein)
- Durcissement (Étape C) : 1 jour
### Durée Estimée
| Phase | Durée | Bloqué Par |
|-------|-------|-----------|
| A (Fondations) | 1-2 j | Aucun |
| B (Déploiement) | 3-5 j | A complète |
| C (Durcissement) | 1 j | B complète |
| **TOTAL** | **5-8 j** | — |
### Dépendances Connues
**Aucune dépendance blocage avec autres chantiers actuels** :
- Worktree guard Qwen : Isolé, pas d'impact (module core/)
- Gap propagation verdict : Isolé (migration replay)
- Mismatch captures persistées : Isolé (API /traces/upload)
**Dépendance suggérée** : Implémenter mitigations § 7.1-7.5 après la migration (durcissement sécurité, effort marginal).
### Recommandation
**GO pour le POC Wallerstein** (approche par étapes) :
1. **Étape A maintenant** : Ajouter table + registre + middleware 2 modes (1-2 jours)
2. **Étape B début Wallerstein** : Redéployer les 5 postes avec tokens uniques (3-5 jours, pendant POC)
3. **Étape C post-POC** : Durcir si compromise d'un poste détectée, ou en fin de POC
Cette approche offre la **révocation chirurgicale** dès l'Étape B (capacité à révoquer un poste sans affecter les autres), tout en gardant une marche arrière jusqu'à la fin de l'Étape B.
---
## Annexe A : Schéma de Synthèse
```
┌─────────────────────────────────────────────────────────────┐
│ Système Actuel (Phase 1 — Token Global) │
├─────────────────────────────────────────────────────────────┤
│ • RPA_API_TOKEN = "a1b2c3d4...xyz" (1 seul, dans .env) │
│ • Tous les postes envoient le MÊME token │
│ • Aucune révocation fine (révoque tout ou rien) │
│ • Audit : machine_id non vérifiable (peut être usurpé) │
└─────────────────────────────────────────────────────────────┘
(Étape A : 1-2j)
┌─────────────────────────────────────────────────────────────┐
│ État Intermédiaire (Phase 1.5 — Dual Mode) │
├─────────────────────────────────────────────────────────────┤
│ • Table postes_tokens ajoutée │
│ • Middleware accepte tokens par-poste OU global │
│ • Anciens agents (global) : continuent à fonctionner │
│ • Nouveaux agents (par-poste) : peuvent être enrôlés │
└─────────────────────────────────────────────────────────────┘
(Étape B : 3-5j, terrain)
┌─────────────────────────────────────────────────────────────┐
│ Cible (Phase 2 — Token Par-Poste) │
├─────────────────────────────────────────────────────────────┤
│ • Poste A : token_A (unique) → machine_id_A │
│ • Poste B : token_B (unique) → machine_id_B │
│ • Poste C : token_C (unique) → machine_id_C │
│ • ... │
│ • Révocation poste A : ne touche pas B, C, D, E │
│ • Audit : machine_id garanti (extrait du token validé) │
└─────────────────────────────────────────────────────────────┘
(Étape C : 1j)
┌─────────────────────────────────────────────────────────────┐
│ Durcissement (Phase 3 — Suppression Global) │
├─────────────────────────────────────────────────────────────┤
│ • RPA_API_TOKEN supprimé du bootstrap │
│ • Middleware accepte UNIQUEMENT tokens par-poste │
│ • Anciens agents (global) : rejetés (404) │
└─────────────────────────────────────────────────────────────┘
```
---
## Annexe B : Checklist de Vérification Pré-Déploiement
- [ ] **DB** : Table postes_tokens créée et indexée, migration de la DB testée
- [ ] **Code** : Middleware dual-mode (par-poste + fallback global) compilé
- [ ] **Cache** : Vérifier que le cache mémoire ne fuit pas (taille bornée à 1000 tokens)
- [ ] **Audit** : Chaque validation loggée avec indication du mode (par-poste ou global)
- [ ] **Endpoint register** : Génère token unique, hash stocké en DB, token retourné UNE FOIS
- [ ] **Endpoint revoke** : Invalidation du token en DB + vidage du cache
- [ ] **Endpoint rotate** : Génère nouveau token, ancien invalidé
- [ ] **UI Dashboard** : Affichage liste postes (actifs + révoqués), boutons revoke/rotate
- [ ] **Tests unitaires** : Vérifier que token global + par-poste fonctionnent (Étape A)
- [ ] **Tests intégration** : Workflow complet (enroll → register → streaming → audit)
- [ ] **Logs de sécurité** : Aucun token en clair, aucune variable d'env loggée
- [ ] **Documentation** : Mise à jour du PLAYBOOK_DSI_RSSI avec procédure par-poste
---
**Document généré** : 2026-06-01
**Auteur** : Audit factuel (sans modifications de code)
**Validation requise avant implémentation** : Dom (chef projet)