feat(store): branchement API — déverrouillage opérateur, labels patients, audit
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
465ddd4e7a
commit
7f999a06f0
|
|
@ -13,16 +13,35 @@ from __future__ import annotations
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
from ..adb import Adb, AdbError
|
from ..adb import Adb, AdbError
|
||||||
from ..provider import ProviderClient
|
from ..provider import ProviderClient
|
||||||
|
from ..store import BadPassword, Store, StoreLocked
|
||||||
|
|
||||||
_WEB_DIR = Path(__file__).resolve().parent.parent / "web"
|
_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:
|
def _install_basic_auth(app: FastAPI) -> None:
|
||||||
|
|
@ -50,8 +69,11 @@ def _install_basic_auth(app: FastAPI) -> None:
|
||||||
return await call_next(request)
|
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()
|
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")
|
app = FastAPI(title="Kazeia-central", version="0.0.1")
|
||||||
_install_basic_auth(app)
|
_install_basic_auth(app)
|
||||||
|
|
||||||
|
|
@ -63,16 +85,56 @@ def create_app(adb: Adb | None = None) -> FastAPI:
|
||||||
return fn()
|
return fn()
|
||||||
except AdbError as e:
|
except AdbError as e:
|
||||||
raise HTTPException(status_code=502, detail=str(e)) from 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
|
except IndexError as e: # endpoint a renvoyé 0 ligne
|
||||||
raise HTTPException(status_code=404, detail="aucune donnée") from e
|
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")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True, "version": app.version}
|
return {"ok": True, "version": app.version}
|
||||||
|
|
||||||
@app.get("/api/devices")
|
@app.get("/api/devices")
|
||||||
def 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")
|
@app.get("/api/devices/{serial}/state")
|
||||||
def state(serial: str):
|
def state(serial: str):
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,20 @@ Posture :
|
||||||
- Métadonnées (serial, profile_id, session_id, timestamps) → **clair**, pour rester
|
- 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
|
requêtables (dashboard, collecte incrémentale, fan-out). Protégées au repos par le
|
||||||
chiffrement disque OS (§8).
|
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
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
|
@ -76,7 +80,8 @@ class Store:
|
||||||
self.path = str(path)
|
self.path = str(path)
|
||||||
self._kdf_ops = kdf_ops
|
self._kdf_ops = kdf_ops
|
||||||
self._kdf_mem = kdf_mem
|
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.row_factory = sqlite3.Row
|
||||||
self._db.execute("PRAGMA journal_mode=WAL")
|
self._db.execute("PRAGMA journal_mode=WAL")
|
||||||
self._db.execute("PRAGMA foreign_keys=ON")
|
self._db.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
@ -85,7 +90,8 @@ class Store:
|
||||||
self._vault: Vault | None = None
|
self._vault: Vault | None = None
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._db.close()
|
with self._lock:
|
||||||
|
self._db.close()
|
||||||
|
|
||||||
def __enter__(self) -> "Store":
|
def __enter__(self) -> "Store":
|
||||||
return self
|
return self
|
||||||
|
|
@ -108,7 +114,8 @@ class Store:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_initialized(self) -> bool:
|
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
|
@property
|
||||||
def is_unlocked(self) -> bool:
|
def is_unlocked(self) -> bool:
|
||||||
|
|
@ -118,25 +125,30 @@ class Store:
|
||||||
def unlock(self, password: str) -> None:
|
def unlock(self, password: str) -> None:
|
||||||
"""Initialise le coffre au 1ᵉʳ appel, sinon valide le mot de passe.
|
"""Initialise le coffre au 1ᵉʳ appel, sinon valide le mot de passe.
|
||||||
Lève `crypto.BadPassword` si incorrect."""
|
Lève `crypto.BadPassword` si incorrect."""
|
||||||
if not self.is_initialized:
|
with self._lock:
|
||||||
from .crypto import _DEFAULT_MEM, _DEFAULT_OPS
|
if not self._get_meta("kdf_salt"):
|
||||||
vault, params, verifier = Vault.setup(
|
from .crypto import _DEFAULT_MEM, _DEFAULT_OPS
|
||||||
password,
|
vault, params, verifier = Vault.setup(
|
||||||
ops=self._kdf_ops if self._kdf_ops is not None else _DEFAULT_OPS,
|
password,
|
||||||
mem=self._kdf_mem if self._kdf_mem is not None else _DEFAULT_MEM,
|
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._vault = Vault.unlock(password, params, self._get_meta("verifier"))
|
||||||
self._set_meta("kdf_ops", str(params.ops).encode())
|
|
||||||
self._set_meta("kdf_mem", str(params.mem).encode())
|
def lock(self) -> None:
|
||||||
self._set_meta("verifier", verifier)
|
"""Oublie la clé en mémoire (re-verrouille sans fermer la base)."""
|
||||||
self._vault = vault
|
self._vault = None
|
||||||
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"))
|
|
||||||
|
|
||||||
def _require_vault(self) -> Vault:
|
def _require_vault(self) -> Vault:
|
||||||
if self._vault is None:
|
if self._vault is None:
|
||||||
|
|
@ -145,100 +157,112 @@ class Store:
|
||||||
|
|
||||||
# ---- mapping serial → label patient (§9) ------------------------------
|
# ---- mapping serial → label patient (§9) ------------------------------
|
||||||
def set_device_label(self, serial: str, label: str, *, now: int) -> None:
|
def set_device_label(self, serial: str, label: str, *, now: int) -> None:
|
||||||
v = self._require_vault()
|
with self._lock:
|
||||||
self._db.execute(
|
v = self._require_vault()
|
||||||
"INSERT INTO devices(serial, label_enc, created_at, last_seen_at) "
|
self._db.execute(
|
||||||
"VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET "
|
"INSERT INTO devices(serial, label_enc, created_at, last_seen_at) "
|
||||||
"label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at",
|
"VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET "
|
||||||
(serial, v.seal(label), now, now),
|
"label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at",
|
||||||
)
|
(serial, v.seal(label), now, now),
|
||||||
self._db.commit()
|
)
|
||||||
|
self._db.commit()
|
||||||
|
|
||||||
def device_label(self, serial: str) -> str | None:
|
def device_label(self, serial: str) -> str | None:
|
||||||
v = self._require_vault()
|
with self._lock:
|
||||||
row = self._db.execute(
|
v = self._require_vault()
|
||||||
"SELECT label_enc FROM devices WHERE serial=?", (serial,)
|
row = self._db.execute(
|
||||||
).fetchone()
|
"SELECT label_enc FROM devices WHERE serial=?", (serial,)
|
||||||
return v.open(row["label_enc"]) if row else None
|
).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]]:
|
def devices(self) -> list[dict[str, Any]]:
|
||||||
v = self._require_vault()
|
with self._lock:
|
||||||
out = []
|
v = self._require_vault()
|
||||||
for r in self._db.execute("SELECT * FROM devices ORDER BY created_at"):
|
return [{
|
||||||
out.append({
|
|
||||||
"serial": r["serial"],
|
"serial": r["serial"],
|
||||||
"label": v.open(r["label_enc"]),
|
"label": v.open(r["label_enc"]),
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"last_seen_at": r["last_seen_at"],
|
"last_seen_at": r["last_seen_at"],
|
||||||
})
|
} for r in self._db.execute("SELECT * FROM devices ORDER BY created_at")]
|
||||||
return out
|
|
||||||
|
|
||||||
# ---- archive des tours (collecte) -------------------------------------
|
# ---- archive des tours (collecte) -------------------------------------
|
||||||
def archive_turns(self, serial: str, turns: Iterable[dict[str, Any]], *, now: int) -> int:
|
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)).
|
"""Insère/ignore des tours (idempotent par (serial, session_id, turn_id)).
|
||||||
Chaque `text` est chiffré. Renvoie le nombre de NOUVEAUX tours archivés."""
|
Chaque `text` est chiffré. Renvoie le nombre de NOUVEAUX tours archivés."""
|
||||||
v = self._require_vault()
|
with self._lock:
|
||||||
before = self._db.total_changes
|
v = self._require_vault()
|
||||||
self._db.executemany(
|
before = self._db.total_changes
|
||||||
"INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, "
|
self._db.executemany(
|
||||||
"role, text_enc, ttft_ms, total_ms, voice_used, model_used, archived_at) "
|
"INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, "
|
||||||
"VALUES(:serial,:profile_id,:session_id,:turn_id,:ts,:role,:text_enc,"
|
"role, text_enc, ttft_ms, total_ms, voice_used, model_used, archived_at) "
|
||||||
":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"),
|
"serial": serial,
|
||||||
"session_id": t["session_id"],
|
"profile_id": t.get("profile_id"),
|
||||||
"turn_id": str(t.get("id", t.get("turn_id"))),
|
"session_id": t["session_id"],
|
||||||
"ts": t.get("timestamp", t.get("ts")),
|
"turn_id": str(t.get("id", t.get("turn_id"))),
|
||||||
"role": t.get("role"),
|
"ts": t.get("timestamp", t.get("ts")),
|
||||||
"text_enc": v.seal(t.get("text")),
|
"role": t.get("role"),
|
||||||
"ttft_ms": t.get("ttft_ms"),
|
"text_enc": v.seal(t.get("text")),
|
||||||
"total_ms": t.get("total_ms"),
|
"ttft_ms": t.get("ttft_ms"),
|
||||||
"voice_used": t.get("voice_used"),
|
"total_ms": t.get("total_ms"),
|
||||||
"model_used": t.get("model_used"),
|
"voice_used": t.get("voice_used"),
|
||||||
"archived_at": now,
|
"model_used": t.get("model_used"),
|
||||||
} for t in turns],
|
"archived_at": now,
|
||||||
)
|
} for t in turns],
|
||||||
self._db.commit()
|
)
|
||||||
return self._db.total_changes - before
|
self._db.commit()
|
||||||
|
return self._db.total_changes - before
|
||||||
|
|
||||||
def session_turns(self, serial: str, session_id: str) -> list[dict[str, Any]]:
|
def session_turns(self, serial: str, session_id: str) -> list[dict[str, Any]]:
|
||||||
v = self._require_vault()
|
with self._lock:
|
||||||
rows = self._db.execute(
|
v = self._require_vault()
|
||||||
"SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id",
|
rows = self._db.execute(
|
||||||
(serial, session_id),
|
"SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id",
|
||||||
).fetchall()
|
(serial, session_id),
|
||||||
out = []
|
).fetchall()
|
||||||
for r in rows:
|
out = []
|
||||||
d = dict(r)
|
for r in rows:
|
||||||
d["text"] = v.open(d.pop("text_enc"))
|
d = dict(r)
|
||||||
out.append(d)
|
d["text"] = v.open(d.pop("text_enc"))
|
||||||
return out
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
def last_turn_ts(self, serial: str, session_id: str | None = None) -> int | None:
|
def last_turn_ts(self, serial: str, session_id: str | None = None) -> int | None:
|
||||||
"""Dernier timestamp archivé → borne `since` pour la collecte incrémentale
|
"""Dernier timestamp archivé → borne `since` pour la collecte incrémentale
|
||||||
(autonomie). Pas besoin du vault (métadonnée en clair)."""
|
(autonomie). Pas besoin du vault (métadonnée en clair)."""
|
||||||
if session_id is None:
|
with self._lock:
|
||||||
row = self._db.execute(
|
if session_id is None:
|
||||||
"SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,)
|
row = self._db.execute(
|
||||||
).fetchone()
|
"SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,)
|
||||||
else:
|
).fetchone()
|
||||||
row = self._db.execute(
|
else:
|
||||||
"SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?",
|
row = self._db.execute(
|
||||||
(serial, session_id),
|
"SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?",
|
||||||
).fetchone()
|
(serial, session_id),
|
||||||
return row["m"] if row else None
|
).fetchone()
|
||||||
|
return row["m"] if row else None
|
||||||
|
|
||||||
# ---- audit (§8) -------------------------------------------------------
|
# ---- audit (§8) -------------------------------------------------------
|
||||||
def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
|
def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
|
||||||
detail: dict[str, Any] | None = None, now: int) -> None:
|
detail: dict[str, Any] | None = None, now: int) -> None:
|
||||||
self._db.execute(
|
with self._lock:
|
||||||
"INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)",
|
self._db.execute(
|
||||||
(now, actor, action, target, json.dumps(detail) if detail else None),
|
"INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)",
|
||||||
)
|
(now, actor, action, target, json.dumps(detail) if detail else None),
|
||||||
self._db.commit()
|
)
|
||||||
|
self._db.commit()
|
||||||
|
|
||||||
def audit_log(self, limit: int = 200) -> list[dict[str, Any]]:
|
def audit_log(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
return [dict(r) for r in self._db.execute(
|
with self._lock:
|
||||||
"SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,)
|
return [dict(r) for r in self._db.execute(
|
||||||
)]
|
"SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,)
|
||||||
|
)]
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,19 @@
|
||||||
display:flex; align-items:center; gap:12px; }
|
display:flex; align-items:center; gap:12px; }
|
||||||
header h1 { font-size:16px; margin:0; font-weight:600; }
|
header h1 { font-size:16px; margin:0; font-weight:600; }
|
||||||
header .sub { color:var(--muted); font-size:12px; }
|
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); }
|
.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; }
|
main { flex:1; overflow:auto; padding:18px 22px; }
|
||||||
.dev { padding:10px 12px; border:1px solid var(--line); border-radius:8px;
|
.dev { padding:10px 12px; border:1px solid var(--line); border-radius:8px;
|
||||||
margin-bottom:8px; cursor:pointer; background:var(--panel); }
|
margin-bottom:8px; cursor:pointer; background:var(--panel); }
|
||||||
.dev:hover { border-color:var(--accent); }
|
.dev:hover { border-color:var(--accent); }
|
||||||
.dev.sel { border-color:var(--accent); background:var(--panel2); }
|
.dev.sel { border-color:var(--accent); background:var(--panel2); }
|
||||||
.dev .serial { font-family:ui-monospace,monospace; font-weight:600; }
|
.dev .name { font-weight:600; display:flex; align-items:center; gap:6px; }
|
||||||
.dev .meta { color:var(--muted); font-size:12px; }
|
.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;
|
.badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px;
|
||||||
border:1px solid var(--line); }
|
border:1px solid var(--line); }
|
||||||
.badge.device { color:var(--ok); border-color:var(--ok); }
|
.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);
|
button { background:var(--panel2); color:var(--txt); border:1px solid var(--line);
|
||||||
padding:6px 12px; border-radius:7px; cursor:pointer; font-size:13px; }
|
padding:6px 12px; border-radius:7px; cursor:pointer; font-size:13px; }
|
||||||
button:hover { border-color:var(--accent); }
|
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; }
|
.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;
|
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; }
|
||||||
padding:14px 16px; }
|
|
||||||
.card h3 { margin:0 0 10px; font-size:13px; color:var(--muted); text-transform:uppercase;
|
.card h3 { margin:0 0 10px; font-size:13px; color:var(--muted); text-transform:uppercase;
|
||||||
letter-spacing:.04em; font-weight:600; }
|
letter-spacing:.04em; font-weight:600; }
|
||||||
.kv { display:flex; justify-content:space-between; gap:10px; padding:3px 0;
|
.kv { display:flex; justify-content:space-between; gap:10px; padding:3px 0;
|
||||||
|
|
@ -58,27 +70,83 @@
|
||||||
<header>
|
<header>
|
||||||
<h1>Kazeia-central</h1>
|
<h1>Kazeia-central</h1>
|
||||||
<span class="sub" id="sub">console de flotte</span>
|
<span class="sub" id="sub">console de flotte</span>
|
||||||
<button onclick="loadDevices()">↻ Rafraîchir le parc</button>
|
<div class="right">
|
||||||
|
<span id="lockchip"></span>
|
||||||
|
<button onclick="loadDevices()">↻ Parc</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside><div id="devices" class="muted">chargement…</div></aside>
|
<aside>
|
||||||
|
<div id="vault"></div>
|
||||||
|
<div id="devices" class="muted">chargement…</div>
|
||||||
|
</aside>
|
||||||
<main><div id="detail"><div id="placeholder">← sélectionne une tablette</div></div></main>
|
<main><div id="detail"><div id="placeholder">← sélectionne une tablette</div></div></main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
let selected = null;
|
let selected = null, unlocked = false;
|
||||||
|
|
||||||
async function api(path) {
|
async function api(path, opts) {
|
||||||
const r = await fetch("/api" + path);
|
const r = await fetch("/api" + path, opts);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
let detail = r.status;
|
let detail = r.status;
|
||||||
try { detail = (await r.json()).detail || detail; } catch {}
|
try { detail = (await r.json()).detail || detail; } catch {}
|
||||||
throw new Error(detail);
|
const e = new Error(detail); e.status = r.status; throw e;
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.status === 204 ? null : r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- coffre opérateur ----------------------------------------------------
|
||||||
|
async function refreshLock() {
|
||||||
|
const st = await api("/lock-status");
|
||||||
|
unlocked = st.unlocked;
|
||||||
|
$("lockchip").className = "lockchip " + (unlocked ? "on" : "off");
|
||||||
|
$("lockchip").textContent = unlocked ? "🔓 store ouvert" : "🔒 store verrouillé";
|
||||||
|
const v = $("vault");
|
||||||
|
if (unlocked) {
|
||||||
|
v.className = "";
|
||||||
|
v.innerHTML = `<b>Store déverrouillé</b> — labels patients actifs.
|
||||||
|
<div class="row"><button onclick="lockStore()">Verrouiller</button></div>`;
|
||||||
|
} else {
|
||||||
|
v.className = "locked";
|
||||||
|
v.innerHTML = `<b>${st.initialized ? "Déverrouiller" : "Initialiser"} le store</b>
|
||||||
|
<div class="muted" style="font-size:12px">${st.initialized
|
||||||
|
? "mot de passe opérateur" : "choisis un mot de passe opérateur (1ʳᵉ fois)"}</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="pw" type="password" placeholder="mot de passe"
|
||||||
|
onkeydown="if(event.key==='Enter')doUnlock()">
|
||||||
|
<button onclick="doUnlock()">OK</button>
|
||||||
|
</div><div id="vaulterr" class="err"></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnlock() {
|
||||||
|
$("vaulterr").textContent = "";
|
||||||
|
try {
|
||||||
|
await api("/unlock", {method:"POST", headers:{"content-type":"application/json"},
|
||||||
|
body: JSON.stringify({password: $("pw").value})});
|
||||||
|
await refreshLock();
|
||||||
|
await loadDevices();
|
||||||
|
} catch (e) { $("vaulterr").textContent = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockStore() {
|
||||||
|
await api("/lock", {method:"POST"});
|
||||||
|
await refreshLock();
|
||||||
|
await loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLabel(serial, current, ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const label = prompt(`Nom du patient pour ${serial} :`, current || "");
|
||||||
|
if (label === null) return;
|
||||||
|
await api(`/devices/${serial}/label`, {method:"PUT",
|
||||||
|
headers:{"content-type":"application/json"}, body: JSON.stringify({label})});
|
||||||
|
await loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- parc ----------------------------------------------------------------
|
||||||
async function loadDevices() {
|
async function loadDevices() {
|
||||||
const box = $("devices");
|
const box = $("devices");
|
||||||
try {
|
try {
|
||||||
|
|
@ -87,15 +155,21 @@ async function loadDevices() {
|
||||||
if (!devs.length) { box.innerHTML = '<div class="muted">aucune tablette branchée</div>'; return; }
|
if (!devs.length) { box.innerHTML = '<div class="muted">aucune tablette branchée</div>'; return; }
|
||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
for (const d of devs) {
|
for (const d of devs) {
|
||||||
|
const label = d.label || null;
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "dev" + (d.serial === selected ? " sel" : "");
|
el.className = "dev" + (d.serial === selected ? " sel" : "");
|
||||||
el.innerHTML = `<div class="serial">${d.serial}</div>
|
el.innerHTML = `
|
||||||
|
<div class="name">${label ? `👤 ${label}` : `<span class="serial">${d.serial}</span>`}
|
||||||
|
${unlocked ? `<button class="edit" title="nommer">✎</button>` : ""}</div>
|
||||||
|
${label ? `<div class="serial">${d.serial}</div>` : ""}
|
||||||
<div class="meta">${d.props?.model || "?"} · <span class="badge ${d.state}">${d.state}</span></div>`;
|
<div class="meta">${d.props?.model || "?"} · <span class="badge ${d.state}">${d.state}</span></div>`;
|
||||||
el.onclick = () => selectDevice(d.serial);
|
el.onclick = () => selectDevice(d.serial);
|
||||||
|
const edit = el.querySelector(".edit");
|
||||||
|
if (edit) edit.onclick = (ev) => setLabel(d.serial, label, ev);
|
||||||
box.appendChild(el);
|
box.appendChild(el);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
box.innerHTML = `<div class="err">erreur adb : ${e.message}</div>`;
|
box.innerHTML = `<div class="err">erreur : ${e.message}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,7 +213,7 @@ async function selectDevice(serial) {
|
||||||
d.innerHTML = `<div class="grid">${state}${updates}${rag}${profiles}${convs}${crashes}</div>`;
|
d.innerHTML = `<div class="grid">${state}${updates}${rag}${profiles}${convs}${crashes}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDevices();
|
(async () => { await refreshLock(); await loadDevices(); })();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue