From 7f999a06f04e66380fb3ac2ae4e63e1a62b9f3d5 Mon Sep 17 00:00:00 2001 From: alf Date: Thu, 18 Jun 2026 22:49:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(store):=20branchement=20API=20=E2=80=94=20?= =?UTF-8?q?d=C3=A9verrouillage=20op=C3=A9rateur,=20labels=20patients,=20au?= =?UTF-8?q?dit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API : /api/lock-status, /api/unlock, /api/lock, PUT /api/devices/{serial}/label, /api/audit ; /api/devices enrichi du label patient quand le store est déverrouillé. - db.py thread-safe (check_same_thread=False + RLock) pour le threadpool FastAPI ; ajout lock(), device_labels(). - UI : barre de déverrouillage opérateur, affichage 👤 label, édition ✎ du nom. - Sécurité : labels chiffrés (jamais en clair en base ni dans l'audit), opérations journalisées, store re-verrouillable. - tests : +5 intégration API↔store (dont non-fuite PII dans l'audit). 19/19. Co-Authored-By: Claude Opus 4.8 (1M context) --- kazeia_central/api/app.py | 66 ++++++++++- kazeia_central/store/db.py | 212 +++++++++++++++++++--------------- kazeia_central/web/index.html | 106 ++++++++++++++--- tests/test_api_store.py | 78 +++++++++++++ 4 files changed, 350 insertions(+), 112 deletions(-) create mode 100644 tests/test_api_store.py diff --git a/kazeia_central/api/app.py b/kazeia_central/api/app.py index 89dc818..9cf0e70 100644 --- a/kazeia_central/api/app.py +++ b/kazeia_central/api/app.py @@ -13,16 +13,35 @@ from __future__ import annotations import base64 import os import secrets +import time from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel from starlette.responses import Response from ..adb import Adb, AdbError from ..provider import ProviderClient +from ..store import BadPassword, Store, StoreLocked _WEB_DIR = Path(__file__).resolve().parent.parent / "web" +# Base chiffrée locale. Défaut sous data/ (ignoré par .gitignore — jamais commité). +_STORE_PATH = Path(os.environ.get("KAZEIA_STORE", Path.cwd() / "data" / "central.db")) +# Identité opérateur pour la piste d'audit (= user d'auth réseau si défini). +_OPERATOR = os.environ.get("KAZEIA_USER") or "local" + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +class _Password(BaseModel): + password: str + + +class _Label(BaseModel): + label: str def _install_basic_auth(app: FastAPI) -> None: @@ -50,8 +69,11 @@ def _install_basic_auth(app: FastAPI) -> None: return await call_next(request) -def create_app(adb: Adb | None = None) -> FastAPI: +def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI: adb = adb or Adb() + if store is None: + _STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + store = Store(_STORE_PATH) app = FastAPI(title="Kazeia-central", version="0.0.1") _install_basic_auth(app) @@ -63,16 +85,56 @@ def create_app(adb: Adb | None = None) -> FastAPI: return fn() except AdbError as e: raise HTTPException(status_code=502, detail=str(e)) from e + except StoreLocked as e: + raise HTTPException(status_code=423, detail=str(e)) from e except IndexError as e: # endpoint a renvoyé 0 ligne raise HTTPException(status_code=404, detail="aucune donnée") from e + # ---- store opérateur (déverrouillage, labels patients, audit) --------- + @app.get("/api/lock-status") + def lock_status(): + return {"initialized": store.is_initialized, "unlocked": store.is_unlocked} + + @app.post("/api/unlock") + def unlock(body: _Password): + first = not store.is_initialized + try: + store.unlock(body.password) + except BadPassword as e: + raise HTTPException(status_code=401, detail="mot de passe incorrect") from e + store.audit("store_init" if first else "store_unlock", + actor=_OPERATOR, now=_now_ms()) + return {"unlocked": True, "initialized_now": first} + + @app.post("/api/lock") + def lock(): + store.lock() + return {"unlocked": False} + + @app.get("/api/audit") + def audit(limit: int = 200): + return _guard(lambda: store.audit_log(limit)) + @app.get("/api/health") def health(): return {"ok": True, "version": app.version} @app.get("/api/devices") def devices(): - return [d.__dict__ for d in _guard(adb.devices)] + devs = [d.__dict__ for d in _guard(adb.devices)] + # Enrichit avec le label patient si le store est déverrouillé. + if store.is_unlocked: + labels = store.device_labels() + for d in devs: + d["label"] = labels.get(d["serial"]) + return devs + + @app.put("/api/devices/{serial}/label") + def set_label(serial: str, body: _Label): + _guard(lambda: store.set_device_label(serial, body.label, now=_now_ms())) + # NB : ne PAS mettre le label (PII) dans audit.detail (colonne en clair). + store.audit("set_label", actor=_OPERATOR, target=serial, now=_now_ms()) + return {"serial": serial, "label": body.label} @app.get("/api/devices/{serial}/state") def state(serial: str): diff --git a/kazeia_central/store/db.py b/kazeia_central/store/db.py index da0018e..8fd9b4b 100644 --- a/kazeia_central/store/db.py +++ b/kazeia_central/store/db.py @@ -9,16 +9,20 @@ Posture : - Métadonnées (serial, profile_id, session_id, timestamps) → **clair**, pour rester requêtables (dashboard, collecte incrémentale, fan-out). Protégées au repos par le chiffrement disque OS (§8). -- Tout accès d'export est journalisé (`audit`). +- Tout accès sensible est journalisé (`audit`). Le store s'ouvre verrouillé ; `unlock(password)` dérive la clé et la garde en mémoire pour la session. Sans unlock, seules les métadonnées sont lisibles, jamais le contenu. + +Thread-safety : les endpoints FastAPI synchrones tournent dans un threadpool → +connexion ouverte avec `check_same_thread=False` et accès sérialisé par un `RLock`. """ from __future__ import annotations import json import sqlite3 +import threading from pathlib import Path from typing import Any, Iterable @@ -76,7 +80,8 @@ class Store: self.path = str(path) self._kdf_ops = kdf_ops self._kdf_mem = kdf_mem - self._db = sqlite3.connect(self.path) + self._lock = threading.RLock() + self._db = sqlite3.connect(self.path, check_same_thread=False) self._db.row_factory = sqlite3.Row self._db.execute("PRAGMA journal_mode=WAL") self._db.execute("PRAGMA foreign_keys=ON") @@ -85,7 +90,8 @@ class Store: self._vault: Vault | None = None def close(self) -> None: - self._db.close() + with self._lock: + self._db.close() def __enter__(self) -> "Store": return self @@ -108,7 +114,8 @@ class Store: @property def is_initialized(self) -> bool: - return self._get_meta("kdf_salt") is not None + with self._lock: + return self._get_meta("kdf_salt") is not None @property def is_unlocked(self) -> bool: @@ -118,25 +125,30 @@ class Store: def unlock(self, password: str) -> None: """Initialise le coffre au 1ᵉʳ appel, sinon valide le mot de passe. Lève `crypto.BadPassword` si incorrect.""" - if not self.is_initialized: - from .crypto import _DEFAULT_MEM, _DEFAULT_OPS - vault, params, verifier = Vault.setup( - password, - ops=self._kdf_ops if self._kdf_ops is not None else _DEFAULT_OPS, - mem=self._kdf_mem if self._kdf_mem is not None else _DEFAULT_MEM, + with self._lock: + if not self._get_meta("kdf_salt"): + from .crypto import _DEFAULT_MEM, _DEFAULT_OPS + vault, params, verifier = Vault.setup( + password, + ops=self._kdf_ops if self._kdf_ops is not None else _DEFAULT_OPS, + mem=self._kdf_mem if self._kdf_mem is not None else _DEFAULT_MEM, + ) + self._set_meta("kdf_salt", params.salt) + self._set_meta("kdf_ops", str(params.ops).encode()) + self._set_meta("kdf_mem", str(params.mem).encode()) + self._set_meta("verifier", verifier) + self._vault = vault + return + params = KdfParams( + salt=self._get_meta("kdf_salt"), + ops=int(self._get_meta("kdf_ops")), + mem=int(self._get_meta("kdf_mem")), ) - self._set_meta("kdf_salt", params.salt) - self._set_meta("kdf_ops", str(params.ops).encode()) - self._set_meta("kdf_mem", str(params.mem).encode()) - self._set_meta("verifier", verifier) - self._vault = vault - return - params = KdfParams( - salt=self._get_meta("kdf_salt"), - ops=int(self._get_meta("kdf_ops")), - mem=int(self._get_meta("kdf_mem")), - ) - self._vault = Vault.unlock(password, params, self._get_meta("verifier")) + self._vault = Vault.unlock(password, params, self._get_meta("verifier")) + + def lock(self) -> None: + """Oublie la clé en mémoire (re-verrouille sans fermer la base).""" + self._vault = None def _require_vault(self) -> Vault: if self._vault is None: @@ -145,100 +157,112 @@ class Store: # ---- mapping serial → label patient (§9) ------------------------------ def set_device_label(self, serial: str, label: str, *, now: int) -> None: - v = self._require_vault() - self._db.execute( - "INSERT INTO devices(serial, label_enc, created_at, last_seen_at) " - "VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET " - "label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at", - (serial, v.seal(label), now, now), - ) - self._db.commit() + with self._lock: + v = self._require_vault() + self._db.execute( + "INSERT INTO devices(serial, label_enc, created_at, last_seen_at) " + "VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET " + "label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at", + (serial, v.seal(label), now, now), + ) + self._db.commit() def device_label(self, serial: str) -> str | None: - v = self._require_vault() - row = self._db.execute( - "SELECT label_enc FROM devices WHERE serial=?", (serial,) - ).fetchone() - return v.open(row["label_enc"]) if row else None + with self._lock: + v = self._require_vault() + row = self._db.execute( + "SELECT label_enc FROM devices WHERE serial=?", (serial,) + ).fetchone() + return v.open(row["label_enc"]) if row else None + + def device_labels(self) -> dict[str, str | None]: + """Tous les labels {serial: label} — pour enrichir la liste du parc.""" + with self._lock: + v = self._require_vault() + return {r["serial"]: v.open(r["label_enc"]) + for r in self._db.execute("SELECT serial, label_enc FROM devices")} def devices(self) -> list[dict[str, Any]]: - v = self._require_vault() - out = [] - for r in self._db.execute("SELECT * FROM devices ORDER BY created_at"): - out.append({ + with self._lock: + v = self._require_vault() + return [{ "serial": r["serial"], "label": v.open(r["label_enc"]), "created_at": r["created_at"], "last_seen_at": r["last_seen_at"], - }) - return out + } for r in self._db.execute("SELECT * FROM devices ORDER BY created_at")] # ---- archive des tours (collecte) ------------------------------------- def archive_turns(self, serial: str, turns: Iterable[dict[str, Any]], *, now: int) -> int: """Insère/ignore des tours (idempotent par (serial, session_id, turn_id)). Chaque `text` est chiffré. Renvoie le nombre de NOUVEAUX tours archivés.""" - v = self._require_vault() - before = self._db.total_changes - self._db.executemany( - "INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, " - "role, text_enc, ttft_ms, total_ms, voice_used, model_used, archived_at) " - "VALUES(:serial,:profile_id,:session_id,:turn_id,:ts,:role,:text_enc," - ":ttft_ms,:total_ms,:voice_used,:model_used,:archived_at)", - [{ - "serial": serial, - "profile_id": t.get("profile_id"), - "session_id": t["session_id"], - "turn_id": str(t.get("id", t.get("turn_id"))), - "ts": t.get("timestamp", t.get("ts")), - "role": t.get("role"), - "text_enc": v.seal(t.get("text")), - "ttft_ms": t.get("ttft_ms"), - "total_ms": t.get("total_ms"), - "voice_used": t.get("voice_used"), - "model_used": t.get("model_used"), - "archived_at": now, - } for t in turns], - ) - self._db.commit() - return self._db.total_changes - before + with self._lock: + v = self._require_vault() + before = self._db.total_changes + self._db.executemany( + "INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, " + "role, text_enc, ttft_ms, total_ms, voice_used, model_used, archived_at) " + "VALUES(:serial,:profile_id,:session_id,:turn_id,:ts,:role,:text_enc," + ":ttft_ms,:total_ms,:voice_used,:model_used,:archived_at)", + [{ + "serial": serial, + "profile_id": t.get("profile_id"), + "session_id": t["session_id"], + "turn_id": str(t.get("id", t.get("turn_id"))), + "ts": t.get("timestamp", t.get("ts")), + "role": t.get("role"), + "text_enc": v.seal(t.get("text")), + "ttft_ms": t.get("ttft_ms"), + "total_ms": t.get("total_ms"), + "voice_used": t.get("voice_used"), + "model_used": t.get("model_used"), + "archived_at": now, + } for t in turns], + ) + self._db.commit() + return self._db.total_changes - before def session_turns(self, serial: str, session_id: str) -> list[dict[str, Any]]: - v = self._require_vault() - rows = self._db.execute( - "SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id", - (serial, session_id), - ).fetchall() - out = [] - for r in rows: - d = dict(r) - d["text"] = v.open(d.pop("text_enc")) - out.append(d) - return out + with self._lock: + v = self._require_vault() + rows = self._db.execute( + "SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id", + (serial, session_id), + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["text"] = v.open(d.pop("text_enc")) + out.append(d) + return out def last_turn_ts(self, serial: str, session_id: str | None = None) -> int | None: """Dernier timestamp archivé → borne `since` pour la collecte incrémentale (autonomie). Pas besoin du vault (métadonnée en clair).""" - if session_id is None: - row = self._db.execute( - "SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,) - ).fetchone() - else: - row = self._db.execute( - "SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?", - (serial, session_id), - ).fetchone() - return row["m"] if row else None + with self._lock: + if session_id is None: + row = self._db.execute( + "SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,) + ).fetchone() + else: + row = self._db.execute( + "SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?", + (serial, session_id), + ).fetchone() + return row["m"] if row else None # ---- audit (§8) ------------------------------------------------------- def audit(self, action: str, *, actor: str | None = None, target: str | None = None, detail: dict[str, Any] | None = None, now: int) -> None: - self._db.execute( - "INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)", - (now, actor, action, target, json.dumps(detail) if detail else None), - ) - self._db.commit() + with self._lock: + self._db.execute( + "INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)", + (now, actor, action, target, json.dumps(detail) if detail else None), + ) + self._db.commit() def audit_log(self, limit: int = 200) -> list[dict[str, Any]]: - return [dict(r) for r in self._db.execute( - "SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,) - )] + with self._lock: + return [dict(r) for r in self._db.execute( + "SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,) + )] diff --git a/kazeia_central/web/index.html b/kazeia_central/web/index.html index d02efa0..c138cdd 100644 --- a/kazeia_central/web/index.html +++ b/kazeia_central/web/index.html @@ -17,16 +17,19 @@ display:flex; align-items:center; gap:12px; } header h1 { font-size:16px; margin:0; font-weight:600; } header .sub { color:var(--muted); font-size:12px; } - header button { margin-left:auto; } + header .right { margin-left:auto; display:flex; align-items:center; gap:10px; } .layout { display:flex; height:calc(100vh - 53px); } - aside { width:300px; border-right:1px solid var(--line); overflow:auto; padding:10px; } + aside { width:310px; border-right:1px solid var(--line); overflow:auto; padding:10px; } main { flex:1; overflow:auto; padding:18px 22px; } .dev { padding:10px 12px; border:1px solid var(--line); border-radius:8px; margin-bottom:8px; cursor:pointer; background:var(--panel); } .dev:hover { border-color:var(--accent); } .dev.sel { border-color:var(--accent); background:var(--panel2); } - .dev .serial { font-family:ui-monospace,monospace; font-weight:600; } - .dev .meta { color:var(--muted); font-size:12px; } + .dev .name { font-weight:600; display:flex; align-items:center; gap:6px; } + .dev .serial { font-family:ui-monospace,monospace; color:var(--muted); font-size:12px; } + .dev .meta { color:var(--muted); font-size:12px; margin-top:2px; } + .edit { margin-left:auto; opacity:.5; font-size:12px; padding:1px 6px; } + .dev:hover .edit { opacity:1; } .badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; border:1px solid var(--line); } .badge.device { color:var(--ok); border-color:var(--ok); } @@ -34,9 +37,18 @@ button { background:var(--panel2); color:var(--txt); border:1px solid var(--line); padding:6px 12px; border-radius:7px; cursor:pointer; font-size:13px; } button:hover { border-color:var(--accent); } + input { background:var(--bg); color:var(--txt); border:1px solid var(--line); + padding:6px 9px; border-radius:7px; font-size:13px; } + #vault { background:var(--panel); border:1px solid var(--line); border-radius:8px; + padding:10px; margin-bottom:10px; font-size:13px; } + #vault.locked { border-color:var(--warn); } + #vault .row { display:flex; gap:6px; margin-top:7px; } + #vault input { flex:1; } + .lockchip { font-size:12px; padding:2px 9px; border-radius:10px; border:1px solid var(--line); } + .lockchip.on { color:var(--ok); border-color:var(--ok); } + .lockchip.off { color:var(--warn); border-color:var(--warn); } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; } - .card { background:var(--panel); border:1px solid var(--line); border-radius:10px; - padding:14px 16px; } + .card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; } .card h3 { margin:0 0 10px; font-size:13px; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; font-weight:600; } .kv { display:flex; justify-content:space-between; gap:10px; padding:3px 0; @@ -58,27 +70,83 @@

Kazeia-central

console de flotte - +
+ + +
- +
← sélectionne une tablette
diff --git a/tests/test_api_store.py b/tests/test_api_store.py new file mode 100644 index 0000000..62faa4a --- /dev/null +++ b/tests/test_api_store.py @@ -0,0 +1,78 @@ +"""Tests d'intégration API ↔ store (unlock, labels patients, audit).""" + +import nacl.pwhash +from fastapi.testclient import TestClient + +from kazeia_central.adb import Adb +from kazeia_central.adb.client import AdbDevice +from kazeia_central.api.app import create_app +from kazeia_central.store import Store + +_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN +_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN + + +class _FakeAdb(Adb): + def __init__(self): + super().__init__() + + def devices(self): + return [AdbDevice(serial="ABC123", state="device", props={"model": "Pad3"})] + + +def _client(tmp_path): + store = Store(tmp_path / "central.db", kdf_ops=_OPS, kdf_mem=_MEM) + return TestClient(create_app(adb=_FakeAdb(), store=store)) + + +def test_lock_unlock_flow(tmp_path): + c = _client(tmp_path) + st = c.get("/api/lock-status").json() + assert st == {"initialized": False, "unlocked": False} + + r = c.post("/api/unlock", json={"password": "oppass"}).json() + assert r["unlocked"] and r["initialized_now"] + assert c.get("/api/lock-status").json()["unlocked"] is True + + +def test_label_requires_unlock_then_persists(tmp_path): + c = _client(tmp_path) + # verrouillé → 423 + assert c.put("/api/devices/ABC123/label", json={"label": "X"}).status_code == 423 + + c.post("/api/unlock", json={"password": "pw"}) + # device listé sans label au départ + devs = c.get("/api/devices").json() + assert devs[0]["serial"] == "ABC123" and devs[0]["label"] is None + # on nomme + c.put("/api/devices/ABC123/label", json={"label": "Mme D."}) + devs = c.get("/api/devices").json() + assert devs[0]["label"] == "Mme D." + + +def test_wrong_password_401(tmp_path): + c = _client(tmp_path) + c.post("/api/unlock", json={"password": "right"}) + # re-lock puis mauvais mdp + c.post("/api/lock") + assert c.post("/api/unlock", json={"password": "wrong"}).status_code == 401 + + +def test_audit_trace(tmp_path): + c = _client(tmp_path) + c.post("/api/unlock", json={"password": "pw"}) + c.put("/api/devices/ABC123/label", json={"label": "Mme D."}) + actions = [a["action"] for a in c.get("/api/audit").json()] + assert "store_init" in actions and "set_label" in actions + # le label (PII) ne doit PAS apparaître en clair dans l'audit + raw = c.get("/api/audit").text + assert "Mme D." not in raw + + +def test_locked_hides_labels(tmp_path): + c = _client(tmp_path) + c.post("/api/unlock", json={"password": "pw"}) + c.put("/api/devices/ABC123/label", json={"label": "Secret"}) + c.post("/api/lock") + devs = c.get("/api/devices").json() + assert "label" not in devs[0] # verrouillé → pas d'enrichissement