Files
rpa_vision_v3/agent_v0/server_v1/agent_registry.py
Dom b20d17882e feat(wp-c): méthode verify_token côté registre (patch 3, inerte)
Ajoute AgentRegistry.verify_token(token) -> machine_id|None : compare le
SHA-256 du token aux token_hash des agents 'active' via hmac.compare_digest
(temps constant). Agent désinstallé/révoqué refusé ; rotation à l'enroll
invalide l'ancien token.

Inerte au runtime : méthode non branchée sur l'auth HTTP (le branchement
derrière flag RPA_FLEET_PER_AGENT_TOKEN sera le Patch 4). api_stream.py
intouché. TDD : 6 tests + non-régression WP-C/WP-B (53 verts). Voir
PLAN-WPC-TDD-EXECUTABLE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:21:04 +02:00

414 lines
16 KiB
Python

# agent_v0/server_v1/agent_registry.py
"""
Registre des agents Lea enrolles sur le parc.
Alimente par les endpoints /api/v1/agents/enroll et /api/v1/agents/uninstall
que l'installeur Inno Setup (`deploy/installer/Lea.iss`) appelle a
l'installation et a la desinstallation sur chaque poste collaborateur.
Stockage : SQLite simple, cohabite avec rpa_data.db dans data/databases/.
Aucune dependance GPU/LLM — ce module doit rester leger (juste sqlite3 +
stdlib) pour pouvoir etre importe par le serveur HTTP.
Schema de la table `enrolled_agents` :
id INTEGER PK AUTOINCREMENT
machine_id TEXT UNIQUE NOT NULL — identifiant genere par l'installeur
user_name TEXT — nom affichage collaborateur
user_email TEXT
user_id TEXT — identifiant metier (ex: AIVA-001)
hostname TEXT
os_info TEXT
version TEXT — version du client Lea
status TEXT DEFAULT 'active''active' | 'uninstalled'
enrolled_at TEXT NOT NULL — ISO 8601 UTC
last_seen_at TEXT — ISO 8601 UTC (heartbeat / stream)
uninstalled_at TEXT
uninstall_reason TEXT
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Verrou global : SQLite tolere plusieurs threads mais on serialise
# les ecritures pour eviter les races sur _init_db + upserts concurrents.
_DB_LOCK = threading.Lock()
def _utc_now_iso() -> str:
"""Horodatage ISO 8601 UTC (compatible toutes les autres tables)."""
return datetime.now(timezone.utc).isoformat()
def _new_token() -> Tuple[str, str]:
"""WP-C : genere un token poste (clair) et son empreinte SHA-256.
Le clair est retourne UNE seule fois a l'appelant (resultat de enroll) ; seul
le hash est persiste dans `token_hash`. Le clair n'est jamais journalise ni
stocke. L'auth runtime reste inchangee (aucun branchement ici sur la
verification de token cote api_stream).
"""
clear = secrets.token_hex(32)
token_hash = hashlib.sha256(clear.encode("utf-8")).hexdigest()
return clear, token_hash
def _fleet_enroll_locked() -> bool:
"""WP-B : parc verrouille -> aucun NOUVEAU machine_id ne peut s'enroler.
Pilote par l'env `RPA_FLEET_ENROLL_LOCKED` (true/1/yes), reversible (relu a
chaque appel). Ferme le contournement « poste revoque + nouveau machine_id +
token global » : les machines deja connues gardent leur comportement, seul
l'enrolement d'un machine_id inconnu est refuse quand le parc est verrouille.
"""
return os.getenv("RPA_FLEET_ENROLL_LOCKED", "").strip().lower() in ("1", "true", "yes")
class AgentRegistry:
"""Gestion CRUD des agents enrolles (SQLite)."""
def __init__(self, db_path: str | Path = "data/databases/rpa_data.db"):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
# ------------------------------------------------------------------
# Infra SQLite
# ------------------------------------------------------------------
def _connect(self) -> sqlite3.Connection:
# check_same_thread=False : on protege nous-memes via _DB_LOCK,
# indispensable car FastAPI appelle les endpoints sur threads
# differents (thread pool).
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _init_db(self) -> None:
"""Cree la table et ses index si absents (idempotent)."""
with _DB_LOCK, self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS enrolled_agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id TEXT NOT NULL UNIQUE,
user_name TEXT,
user_email TEXT,
user_id TEXT,
hostname TEXT,
os_info TEXT,
version TEXT,
status TEXT NOT NULL DEFAULT 'active',
enrolled_at TEXT NOT NULL,
last_seen_at TEXT,
uninstalled_at TEXT,
uninstall_reason TEXT
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_enrolled_agents_status "
"ON enrolled_agents(status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_enrolled_agents_machine "
"ON enrolled_agents(machine_id)"
)
# WP-C Patch 1 : colonnes « token par poste », migration additive
# idempotente. Inertes tant que l'auth par poste n'est pas branchée
# (patchs WP-C ultérieurs). Voir DETTE-015.
existing_cols = {
row[1]
for row in conn.execute(
"PRAGMA table_info(enrolled_agents)"
).fetchall()
}
for col in ("token_hash", "token_issued_at"):
if col not in existing_cols:
conn.execute(
f"ALTER TABLE enrolled_agents ADD COLUMN {col} TEXT"
)
# ------------------------------------------------------------------
# Lecture
# ------------------------------------------------------------------
def get(self, machine_id: str) -> Optional[Dict[str, Any]]:
"""Recupere un agent par machine_id (ou None)."""
with _DB_LOCK, self._connect() as conn:
row = conn.execute(
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
return dict(row) if row else None
def list_by_status(self, status: str) -> List[Dict[str, Any]]:
"""Liste les agents par statut ('active' | 'uninstalled')."""
with _DB_LOCK, self._connect() as conn:
rows = conn.execute(
"SELECT * FROM enrolled_agents WHERE status = ? "
"ORDER BY enrolled_at DESC",
(status,),
).fetchall()
return [dict(r) for r in rows]
def count_by_status(self, status: str) -> int:
with _DB_LOCK, self._connect() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM enrolled_agents WHERE status = ?",
(status,),
).fetchone()
return int(row["n"]) if row else 0
def verify_token(self, token: str | None) -> Optional[str]:
"""WP-C : verifie un token poste, retourne le machine_id actif ou None.
Compare le SHA-256 du token presente aux `token_hash` des agents
`status='active'` via `hmac.compare_digest` (comparaison a temps
constant, evite les fuites par timing). Un agent desinstalle/revoque
n'est pas 'active' donc refuse ; la rotation a l'enrolement invalide
l'ancien token.
INERTE : non branchee sur l'auth runtime (le branchement derriere flag
sera le Patch 4). Aucun appelant runtime a ce stade.
"""
if not token:
return None
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
with _DB_LOCK, self._connect() as conn:
rows = conn.execute(
"SELECT machine_id, token_hash FROM enrolled_agents "
"WHERE status = 'active' AND token_hash IS NOT NULL"
).fetchall()
for row in rows:
if hmac.compare_digest(str(row["token_hash"]), token_hash):
return str(row["machine_id"])
return None
# ------------------------------------------------------------------
# Ecriture
# ------------------------------------------------------------------
def enroll(
self,
*,
machine_id: str,
user_name: str | None = None,
user_email: str | None = None,
user_id: str | None = None,
hostname: str | None = None,
os_info: str | None = None,
version: str | None = None,
allow_reactivate: bool = True,
) -> Dict[str, Any]:
"""Enregistre un nouvel agent ou reactive un agent desinstalle.
Returns:
dict avec clefs {"created": bool, "reactivated": bool, "agent": row}
Raises:
ValueError: si machine_id est vide.
AgentAlreadyEnrolledError: si deja actif (status=active).
"""
if not machine_id or not machine_id.strip():
raise ValueError("machine_id est obligatoire")
machine_id = machine_id.strip()
now = _utc_now_iso()
with _DB_LOCK, self._connect() as conn:
existing = conn.execute(
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
if existing is not None:
if existing["status"] == "active":
# Deja enrolle et actif -> conflit explicit
raise AgentAlreadyEnrolledError(dict(existing))
if existing["uninstall_reason"] == "admin_revoke":
raise AgentRevokedError(dict(existing))
# Agent desinstalle : reactivation si autorise (defaut)
if not allow_reactivate:
raise AgentAlreadyEnrolledError(dict(existing))
# WP-C : rotation du token a chaque (re)enrolement.
token, token_hash = _new_token()
conn.execute(
"""
UPDATE enrolled_agents
SET user_name = COALESCE(?, user_name),
user_email = COALESCE(?, user_email),
user_id = COALESCE(?, user_id),
hostname = COALESCE(?, hostname),
os_info = COALESCE(?, os_info),
version = COALESCE(?, version),
status = 'active',
enrolled_at = ?,
last_seen_at = ?,
uninstalled_at = NULL,
uninstall_reason = NULL,
token_hash = ?,
token_issued_at = ?
WHERE machine_id = ?
""",
(
user_name, user_email, user_id,
hostname, os_info, version,
now, now,
token_hash, now,
machine_id,
),
)
conn.commit()
row = conn.execute(
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
return {
"created": False,
"reactivated": True,
"agent": dict(row),
"token": token,
}
# Nouvelle inscription — WP-B : refusee si le parc est verrouille
if _fleet_enroll_locked():
raise FleetEnrollLockedError(machine_id)
# WP-C : token poste genere a la creation.
token, token_hash = _new_token()
conn.execute(
"""
INSERT INTO enrolled_agents (
machine_id, user_name, user_email, user_id,
hostname, os_info, version,
status, enrolled_at, last_seen_at,
token_hash, token_issued_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
""",
(
machine_id, user_name, user_email, user_id,
hostname, os_info, version,
now, now,
token_hash, now,
),
)
conn.commit()
row = conn.execute(
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
return {
"created": True,
"reactivated": False,
"agent": dict(row),
"token": token,
}
def uninstall(
self,
*,
machine_id: str,
reason: str | None = None,
) -> Optional[Dict[str, Any]]:
"""Marque un agent comme desinstalle (soft delete).
Returns:
Le row mis a jour, ou None si l'agent n'existe pas.
"""
if not machine_id or not machine_id.strip():
raise ValueError("machine_id est obligatoire")
machine_id = machine_id.strip()
now = _utc_now_iso()
with _DB_LOCK, self._connect() as conn:
existing = conn.execute(
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
if existing is None:
return None
conn.execute(
"""
UPDATE enrolled_agents
SET status = 'uninstalled',
uninstalled_at = ?,
uninstall_reason = ?
WHERE machine_id = ?
""",
(now, reason, machine_id),
)
conn.commit()
row = conn.execute(
"SELECT * FROM enrolled_agents WHERE machine_id = ?",
(machine_id,),
).fetchone()
return dict(row)
def touch_last_seen(self, machine_id: str) -> None:
"""Met a jour last_seen_at (appel depuis le stream / heartbeat).
Silencieux si l'agent est inconnu (evite les erreurs sur vieux clients).
Ne reactive jamais un agent desinstalle/revoque.
"""
if not machine_id:
return
now = _utc_now_iso()
with _DB_LOCK, self._connect() as conn:
conn.execute(
"UPDATE enrolled_agents SET last_seen_at = ? "
"WHERE machine_id = ? AND status = 'active'",
(now, machine_id),
)
conn.commit()
class AgentAlreadyEnrolledError(Exception):
"""Levee si on tente d'enrouler une machine deja active."""
def __init__(self, existing_row: Dict[str, Any]):
self.existing = existing_row
super().__init__(
f"machine_id={existing_row.get('machine_id')} deja enrole "
f"(status={existing_row.get('status')})"
)
class AgentRevokedError(Exception):
"""Levee si un administrateur a revoque ce machine_id."""
def __init__(self, existing_row: Dict[str, Any]):
self.existing = existing_row
super().__init__(
f"machine_id={existing_row.get('machine_id')} revoque "
f"(reason={existing_row.get('uninstall_reason')})"
)
class FleetEnrollLockedError(Exception):
"""Levee si le parc est verrouille (RPA_FLEET_ENROLL_LOCKED) et qu'on tente
d'enroler un nouveau machine_id inconnu (WP-B)."""
def __init__(self, machine_id: str):
self.machine_id = machine_id
super().__init__(
f"enrolement refuse : parc verrouille (RPA_FLEET_ENROLL_LOCKED), "
f"machine_id={machine_id} inconnu"
)