# 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" )