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
-
+