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>
414 lines
16 KiB
Python
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"
|
|
)
|