Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9192ef355 | ||
|
|
4c9bfd576c | ||
|
|
20898cade7 | ||
|
|
2c12639499 | ||
|
|
7d2b2067ef | ||
|
|
0b52e6ee86 | ||
|
|
cafac69008 | ||
|
|
871875b8e5 | ||
|
|
e5ae3e48d6 | ||
|
|
76de3dd75e | ||
|
|
687427381d | ||
|
|
e418d87802 | ||
|
|
a1f3690085 | ||
|
|
0a7bed10bf | ||
|
|
32996ce9aa | ||
|
|
b42265b41e | ||
|
|
0fc7654f6c | ||
|
|
effad97472 | ||
|
|
53d98bfffe | ||
|
|
62aa6079be | ||
|
|
7d48f65ff9 | ||
|
|
3acadf6ad7 | ||
|
|
5492f99f06 | ||
|
|
71221e055b | ||
|
|
86330e8f65 | ||
|
|
f3b507b8d9 | ||
|
|
c01c7e862d | ||
|
|
99cd765997 | ||
|
|
5266506b56 | ||
|
|
682a5030d3 | ||
|
|
fc3d847d85 | ||
|
|
82756030c6 | ||
|
|
0352451ead | ||
|
|
593cf492c3 | ||
|
|
0b8ad8b0f3 | ||
|
|
111d927ece | ||
|
|
2dae023ee3 | ||
|
|
b12122f78a | ||
|
|
0c7b3162c8 | ||
|
|
d4d050297b | ||
|
|
8d768bf9d5 | ||
|
|
47ac8bf6c0 | ||
|
|
01572736c0 | ||
|
|
b6e5f01748 | ||
|
|
90da0ac990 | ||
|
|
bc26d1506b | ||
|
|
769e3ca559 | ||
|
|
b5d789f02a | ||
|
|
c8dce25e0d | ||
|
|
b322e2d24a | ||
|
|
414ec69c0a | ||
|
|
f5ca656c04 | ||
|
|
e158232aba | ||
|
|
632871ac24 | ||
|
|
7af7b2c8c2 | ||
|
|
97132770e4 | ||
|
|
683ad3ef94 | ||
|
|
604383f51c | ||
|
|
bdeb2f00b3 | ||
|
|
8688875fae | ||
|
|
a42271c7de | ||
|
|
cf4685eaf1 | ||
|
|
5023fb6774 | ||
|
|
04eb70cbe4 | ||
|
|
4dc010f54d | ||
|
|
f7e36bc44e | ||
|
|
a9ac1c06a7 | ||
|
|
9bf6e7492f | ||
|
|
14070cef43 | ||
|
|
6b1abbb50e | ||
|
|
46c641ebc5 | ||
|
|
2d036f5126 | ||
|
|
2c69e357b8 | ||
|
|
ba81f22d40 | ||
|
|
06c12d0383 | ||
|
|
69f3968d4a | ||
|
|
d5ae66466a | ||
|
|
2dc8be3127 | ||
|
|
040e80a36d | ||
|
|
bd6b79fb41 | ||
|
|
581a31161c | ||
|
|
5f8f93ba6c | ||
|
|
40b3e9c5dd | ||
|
|
2ffc219f47 | ||
|
|
02394d3278 | ||
|
|
ff1ccca3cd |
@@ -132,3 +132,13 @@ DEEPSEEK_MODEL=deepseek-chat
|
||||
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
||||
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
||||
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
|
||||
|
||||
# ── Coffre Secrets ObsiGate (Phases 1–2, voir docs/COFFRE-sauvegarde-restauration.md) ──
|
||||
# Activation (défaut : désactivé — toutes les routes répondent 503 sinon) :
|
||||
# OBSIGATE_SECRETS_ENABLED=true
|
||||
# Clé maître 256 bits, base64 de 32 octets, rendue une seule fois par
|
||||
# POST /api/coffres/admin/generer-kek ; à sauvegarder HORS dépôt, HORS note :
|
||||
# OBSIGATE_KEK=<kek-base64-32-octets>
|
||||
# … ou fichier 0600, hors dépôt et hors arborescence des vaults :
|
||||
# OBSIGATE_KEK_FILE=/chemin/hors/depot/kek.txt
|
||||
# Aucune autre variable ne doit contenir une valeur de secret du coffre.
|
||||
|
||||
+29
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.79.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.81.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,34 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.81.0] — 2026-10-28
|
||||
|
||||
---
|
||||
|
||||
## [2.80.2] — 2026-10-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **CI lint (mypy)** — 3 erreurs dans le Coffre Secrets : annotation explicite `type[Any] | None` pour `AESGCM` dans `coffre_crypto.py` (le repli sans `cryptography` reste `None`, et `_require_aesgcm()` retourne désormais la classe pour un narrowing propre) ; annotation `dict[str, Any]` du coffre dans `create_coffre` (`coffre_store.py`), qui inférait ses valeurs en `str | None` et refusait le dict de `wrap_dek` ainsi que `coffre["id"]` en argument. Aucun `type: ignore` aveugle ajouté, aucun changement de comportement.
|
||||
|
||||
- **Bouton Nouveau secret inoperant (console NotFoundError)** — `valeurField` appelait `wrap.insertBefore(input, eyeBtn)` alors que le bouton oeil n'avait jamais ete rattache a `wrap` : l'oeil est desormais ajoute a `wrap` avant le premier `build`, la modale de creation (et celle de rotation) s'ouvre.
|
||||
- **CI security (pip-audit)** — plancher `msgpack>=1.2.3` : la version transitive 1.2.1 etait signalee pour CVE-2026-114613 (correctif 1.2.3).
|
||||
|
||||
---
|
||||
|
||||
## [2.80.1] — 2026-10-27
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Coffre Secrets — Phase 1** (contenu de la release 2.80.0, dont l'entrée changelog manquait) : chiffrement par enveloppe AES-256-GCM, API `/api/coffres`, nœud virtuel Secrets sous le Home de l'utilisateur.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Étiquette de version 2.80.0** — le tag v2.80.0 pointait sur un contenu dont le fichier `VERSION` valait encore 2.79.0 (un merge ne déclenche pas le bump automatique) ; `VERSION` et tous les fichiers dérivés passent à 2.80.1, pour que la version affichée corresponde au tag de la prochaine release.
|
||||
- **CI lint (ruff)** — 60 erreurs corrigées dans `backend/` : alias `datetime.UTC` (Python 3.11+), `TimeoutError` natif, tri d'imports et import inutilisé dans `coffre_store.py`, `.encode()` sans argument redondant, `fromisoformat` gérant « Z » ; aucune règle ruff masquée, aucun changement de comportement.
|
||||
|
||||
---
|
||||
|
||||
## [2.79.0] — 2026-10-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.79.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.79.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.81.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.79.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.79.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -244,7 +244,7 @@ def _build_stats_payload() -> dict:
|
||||
"disk_used_gb": disk_used_gb,
|
||||
"disk_total_gb": disk_total_gb,
|
||||
"uptime_seconds": int(_server_uptime_seconds()),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+41
-6
@@ -10,7 +10,7 @@ Format: JSON lines (one JSON object per line) for easy parsing.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.audit")
|
||||
@@ -54,7 +54,7 @@ def log_file_save(
|
||||
):
|
||||
"""Log a file save (PUT) operation."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "file_save",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -72,7 +72,7 @@ def log_file_delete(
|
||||
):
|
||||
"""Log a file delete operation."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "file_delete",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -88,7 +88,7 @@ def log_config_change(
|
||||
):
|
||||
"""Log a configuration change."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "config_change",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -99,7 +99,7 @@ def log_config_change(
|
||||
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
|
||||
"""Log a vault addition."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "vault_add",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -111,7 +111,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | Non
|
||||
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
||||
"""Log a vault removal."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "vault_remove",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -119,6 +119,41 @@ def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
||||
})
|
||||
|
||||
|
||||
def log_coffre_event(
|
||||
username: str,
|
||||
action: str,
|
||||
coffre: str | None = None,
|
||||
secret_ref: str | None = None,
|
||||
consommateur: str | None = None,
|
||||
resultat: str | None = None,
|
||||
contexte_id: str | None = None,
|
||||
ip: str | None = None,
|
||||
):
|
||||
"""Journal d'un événement du coffre Secrets (Phase 1b).
|
||||
|
||||
Règle absolue : aucun champ ne contient jamais une valeur de secret,
|
||||
un texte chiffré, un nonce ou un jeton. Seules des références
|
||||
(coffre:<slug>/<nom>), des identifiants et des codes de résultat.
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": action,
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
}
|
||||
if coffre is not None:
|
||||
entry["coffre"] = coffre
|
||||
if secret_ref is not None:
|
||||
entry["secret_ref"] = secret_ref
|
||||
if consommateur is not None:
|
||||
entry["consommateur"] = consommateur
|
||||
if resultat is not None:
|
||||
entry["resultat"] = resultat
|
||||
if contexte_id is not None:
|
||||
entry["contexte_id"] = contexte_id
|
||||
_write_entry(entry)
|
||||
|
||||
|
||||
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
|
||||
"""Read the most recent audit log entries.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import binascii
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, validator
|
||||
@@ -696,7 +697,7 @@ async def mfa_webauthn_register(
|
||||
|
||||
Returns recovery codes when MFA is newly enabled (they were never issued).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_registration, resolve_relying_party
|
||||
@@ -716,7 +717,7 @@ async def mfa_webauthn_register(
|
||||
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
||||
raise HTTPException(400, "Validation du credential WebAuthn échouée")
|
||||
|
||||
record["registered_at"] = datetime.now(timezone.utc).isoformat()
|
||||
record["registered_at"] = datetime.now(UTC).isoformat()
|
||||
creds = list(user.get("webauthn_credentials", []))
|
||||
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
||||
creds.append(record)
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from .password import hash_password
|
||||
@@ -97,8 +97,8 @@ def create_user(
|
||||
"active": True,
|
||||
"language": "fr", # default UI language
|
||||
"avatar": None, # profile picture data-URL (#113)
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"password_changed_at": datetime.now(timezone.utc).timestamp(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"password_changed_at": datetime.now(UTC).timestamp(),
|
||||
"last_login": None,
|
||||
"failed_attempts": 0,
|
||||
"locked_until": None,
|
||||
@@ -126,7 +126,7 @@ def update_user(username: str, updates: dict) -> dict:
|
||||
if "password" in safe_updates:
|
||||
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
||||
# BUG-028: invalidate every token issued before this change.
|
||||
safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp()
|
||||
safe_updates["password_changed_at"] = datetime.now(UTC).timestamp()
|
||||
|
||||
data["users"][username].update(safe_updates)
|
||||
_write(data)
|
||||
@@ -148,7 +148,7 @@ def delete_user(username: str):
|
||||
def record_login_success(username: str):
|
||||
"""Record a successful login: update last_login, reset failed attempts."""
|
||||
update_user(username, {
|
||||
"last_login": datetime.now(timezone.utc).isoformat(),
|
||||
"last_login": datetime.now(UTC).isoformat(),
|
||||
"failed_attempts": 0,
|
||||
"locked_until": None,
|
||||
})
|
||||
@@ -171,7 +171,7 @@ def record_login_failure(username: str) -> int:
|
||||
# Lock after 5 failed attempts (15 minutes)
|
||||
if attempts >= 5:
|
||||
locked_until = (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
datetime.now(UTC) + timedelta(minutes=15)
|
||||
).isoformat()
|
||||
updates["locked_until"] = locked_until
|
||||
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
||||
@@ -189,7 +189,7 @@ def is_locked(username: str) -> bool:
|
||||
if not user or not user.get("locked_until"):
|
||||
return False
|
||||
locked_until = datetime.fromisoformat(user["locked_until"])
|
||||
if datetime.now(timezone.utc) > locked_until:
|
||||
if datetime.now(UTC) > locked_until:
|
||||
# Lock expired — unlock
|
||||
update_user(username, {"locked_until": None, "failed_attempts": 0})
|
||||
return False
|
||||
|
||||
@@ -591,7 +591,7 @@ async def api_bookslm_agent(
|
||||
while True:
|
||||
try:
|
||||
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
if run_task.done():
|
||||
break
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Chiffrement par enveloppe du coffre Secrets ObsiGate (Phase 1a).
|
||||
|
||||
Principe (spec §5) : deux niveaux de clés, jamais de valeur en clair au repos.
|
||||
|
||||
- KEK : clé maître 256 bits, fournie par l'opérateur via ``OBSIGATE_KEK``
|
||||
(base64, 32 octets) ou ``OBSIGATE_KEK_FILE`` (fichier 0600, hors dépôt,
|
||||
hors arborescence des vaults). Elle n'est JAMAIS dérivée du secret
|
||||
d'application, jamais journalisée, jamais renvoyée par API.
|
||||
- DEK : une clé aléatoire par coffre, stockée uniquement chiffrée par la KEK.
|
||||
- Version de secret : ciphertext AES-256-GCM + nonce unique + tag GCM
|
||||
(le tag est inclus dans le ciphertext renvoyé par ``AESGCM.encrypt``).
|
||||
Un tag invalide ou un fichier altéré = refus, jamais de valeur fausse.
|
||||
|
||||
Le module reste importable sans ``cryptography`` installé : les opérations
|
||||
échouent alors avec ``CryptoIndisponibleError`` au lieu de casser le
|
||||
démarrage de l'application (le routeur répond 503 dans ce cas).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.coffre_crypto")
|
||||
|
||||
# Annotation explicite : sans elle, mypy refuse l'assignation de deux types
|
||||
# (la classe AESGCM importée, puis None dans le repli sans cryptography)
|
||||
# et le commentaire `type: ignore[assignment]` ne couvre pas ce code [misc].
|
||||
AESGCM: type[Any] | None
|
||||
try: # cryptography est une dépendance de la Phase 1a (requirements.txt)
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
except Exception: # pragma: no cover - environnement sans cryptography
|
||||
AESGCM = None
|
||||
InvalidTag = Exception # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
class CoffreCryptoError(Exception):
|
||||
"""Erreur générique du chiffrement du coffre."""
|
||||
|
||||
|
||||
class KEKAbsenteError(CoffreCryptoError):
|
||||
"""Aucune KEK configurée (ni OBSIGATE_KEK, ni OBSIGATE_KEK_FILE)."""
|
||||
|
||||
|
||||
class KEKInvalideError(CoffreCryptoError):
|
||||
"""KEK mal formée (ni base64 32 octets, ni hex 64 caractères)."""
|
||||
|
||||
|
||||
class TagInvalideError(CoffreCryptoError):
|
||||
"""Authentification GCM échouée : données altérées ou mauvaise clé."""
|
||||
|
||||
|
||||
class CryptoIndisponibleError(CoffreCryptoError):
|
||||
"""La bibliothèque ``cryptography`` n'est pas installée."""
|
||||
|
||||
|
||||
def _require_aesgcm() -> type[Any]:
|
||||
"""Retourne la classe AESGCM, ou lève si cryptography est absent.
|
||||
|
||||
Retourner la classe (plutôt qu'un simple garde) permet à mypy de
|
||||
savoir que l'appelant détient bien un constructible, et non None.
|
||||
"""
|
||||
if AESGCM is None:
|
||||
raise CryptoIndisponibleError(
|
||||
"cryptography n'est pas installé (dépendance Phase 1a)"
|
||||
)
|
||||
return AESGCM
|
||||
|
||||
|
||||
def parse_kek(raw: str) -> bytes:
|
||||
"""Parse une KEK : base64 (32 octets) ou hex (64 caractères).
|
||||
|
||||
Accepte le base64 standard et URL-safe, avec ou sans padding.
|
||||
"""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
raise KEKInvalideError("KEK vide")
|
||||
# Hex d'abord si la forme correspond (64 caractères hex).
|
||||
if len(value) == 64:
|
||||
try:
|
||||
decoded = bytes.fromhex(value)
|
||||
except ValueError:
|
||||
decoded = b""
|
||||
if len(decoded) == 32:
|
||||
return decoded
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
for decoder in (base64.urlsafe_b64decode, base64.b64decode):
|
||||
try:
|
||||
decoded = decoder(padded)
|
||||
except (binascii.Error, ValueError):
|
||||
continue
|
||||
if len(decoded) == 32:
|
||||
return decoded
|
||||
raise KEKInvalideError("KEK invalide : attendu base64 de 32 octets")
|
||||
|
||||
|
||||
def generate_kek_b64() -> str:
|
||||
"""Génère une KEK 256 bits, encodée base64 (à sauvegarder hors dépôt)."""
|
||||
return base64.b64encode(os.urandom(32)).decode("ascii")
|
||||
|
||||
|
||||
def load_kek() -> bytes:
|
||||
"""Charge la KEK depuis l'environnement ou le fichier dédié.
|
||||
|
||||
Priorité : ``OBSIGATE_KEK`` puis ``OBSIGATE_KEK_FILE``. Le fichier doit
|
||||
être en 0600 (aucun droit groupe/autre) ; sinon, refus explicite.
|
||||
"""
|
||||
env_kek = os.environ.get("OBSIGATE_KEK", "").strip()
|
||||
if env_kek:
|
||||
return parse_kek(env_kek)
|
||||
kek_file = os.environ.get("OBSIGATE_KEK_FILE", "").strip()
|
||||
if kek_file:
|
||||
path = Path(kek_file)
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
except OSError as exc:
|
||||
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
|
||||
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||
raise KEKInvalideError(
|
||||
"Fichier KEK trop permissif : attendu 0600, "
|
||||
f"droits groupe/autre détectés sur {kek_file}"
|
||||
)
|
||||
try:
|
||||
return parse_kek(path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
|
||||
raise KEKAbsenteError("Aucune KEK configurée (OBSIGATE_KEK / OBSIGATE_KEK_FILE)")
|
||||
|
||||
|
||||
def kek_presente() -> bool:
|
||||
"""True si une KEK valide est chargeable, False sinon (jamais d'exception)."""
|
||||
try:
|
||||
load_kek()
|
||||
except CoffreCryptoError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def generate_dek() -> bytes:
|
||||
"""Génère une DEK aléatoire 256 bits pour un coffre."""
|
||||
return os.urandom(32)
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _unb64(value: str) -> bytes:
|
||||
return base64.b64decode(value)
|
||||
|
||||
|
||||
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
|
||||
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
|
||||
aesgcm = _require_aesgcm()
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = aesgcm(key).encrypt(nonce, plaintext, aad)
|
||||
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
|
||||
|
||||
|
||||
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
|
||||
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
|
||||
aesgcm = _require_aesgcm()
|
||||
try:
|
||||
return aesgcm(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
|
||||
except InvalidTag as exc:
|
||||
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise TagInvalideError("Données chiffrées mal formées") from exc
|
||||
|
||||
|
||||
def wrap_dek(dek: bytes, kek: bytes, coffre_id: str) -> dict:
|
||||
"""Chiffre une DEK avec la KEK (AAD = identifiant du coffre)."""
|
||||
return encrypt_with_key(kek, dek, coffre_id.encode("utf-8"))
|
||||
|
||||
|
||||
def unwrap_dek(wrapped: dict, kek: bytes, coffre_id: str) -> bytes:
|
||||
"""Déchiffre une DEK avec la KEK."""
|
||||
return decrypt_with_key(
|
||||
kek, wrapped["nonce"], wrapped["ciphertext"], coffre_id.encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def encrypt_secret_value(dek: bytes, secret_id: str, version: int, valeur: str) -> dict:
|
||||
"""Chiffre une valeur de secret (AAD = secret_id:version)."""
|
||||
aad = f"{secret_id}:{version}".encode()
|
||||
return encrypt_with_key(dek, valeur.encode("utf-8"), aad)
|
||||
|
||||
|
||||
def decrypt_secret_value(
|
||||
dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str
|
||||
) -> str:
|
||||
"""Déchiffre une valeur de secret."""
|
||||
aad = f"{secret_id}:{version}".encode()
|
||||
return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8")
|
||||
|
||||
|
||||
def reencrypt_secret_value(
|
||||
ancienne_dek: bytes,
|
||||
nouvelle_dek: bytes,
|
||||
secret_id: str,
|
||||
version: int,
|
||||
nonce_b64: str,
|
||||
ciphertext_b64: str,
|
||||
) -> dict:
|
||||
"""Re-chiffre une version avec une nouvelle DEK (rotation DEK)."""
|
||||
valeur = decrypt_secret_value(
|
||||
ancienne_dek, secret_id, version, nonce_b64, ciphertext_b64
|
||||
)
|
||||
return encrypt_secret_value(nouvelle_dek, secret_id, version, valeur)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Migration Phase 2 du coffre Secrets ObsiGate (issue #20).
|
||||
|
||||
Bascule des clés aujourd'hui en clair dans ``data/api_keys.json`` vers le
|
||||
coffre d'instance « ObsiGate interne » (slug ``obsigate-interne``).
|
||||
|
||||
Format source (constaté dans ``backend/ai.py`` et
|
||||
``backend/tools/secrets.py``) : un dictionnaire plat ``{NOM: valeur}``.
|
||||
Seules les entrées dont le nom ressemble à un secret sont migrables :
|
||||
clés IA et clés d'outils. Les valeurs non secrètes (par exemple
|
||||
``OBSIGATE_GITEA_URL``) ne sont JAMAIS migrées et ne sont jamais
|
||||
touchées par la purge.
|
||||
|
||||
Règles de sécurité, non négociables :
|
||||
- Aucune fonction de ce module ne renvoie une valeur, ne la journalise,
|
||||
ni ne l'écrit ailleurs que dans le coffre (chiffrée, via le store).
|
||||
Les aperçus ne contiennent que des noms, des types et ``has_value``.
|
||||
- L'import ne supprime rien : il crée des secrets dans le coffre et
|
||||
laisse ``api_keys.json`` intact. La purge est une étape séparée, qui
|
||||
exige ``confirmer=True`` et ne supprime que les noms listés.
|
||||
- Un secret déjà présent dans le coffre n'est JAMAIS écrasé : il est
|
||||
signalé dans ``deja_existants`` et la valeur du coffre est conservée.
|
||||
|
||||
Limite connue, documentée dans ``docs/COFFRE-sauvegarde-restauration.md`` :
|
||||
les lecteurs actuels (``backend/ai.py``, ``backend/tools/secrets.py``)
|
||||
lisent encore ``api_keys.json`` (chemin relatif ``data/``, sans passer
|
||||
par ``OBSIGATE_DATA_DIR``). Purger le clair sans avoir branché le
|
||||
lecteur concerné sur le coffre rend donc la clé inaccessible à ce
|
||||
lecteur : la purge reste un choix explicite de l'opérateur, confirmé
|
||||
deux fois côté interface (import, puis purge).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.coffre_store import (
|
||||
ValidationError,
|
||||
create_coffre,
|
||||
create_secret,
|
||||
get_coffre,
|
||||
get_secret,
|
||||
resolve_data_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.coffre_migration")
|
||||
|
||||
_lock = threading.RLock()
|
||||
|
||||
COFFRE_INTERNE_SLUG = "obsigate-interne"
|
||||
COFFRE_INTERNE_NOM = "ObsiGate interne"
|
||||
|
||||
# (nom dans api_keys.json, type de secret, service d'affichage).
|
||||
# Miroir des clés lues par backend/ai.py (clés IA) et par
|
||||
# backend/tools/secrets.py TOOL_KEY_NAMES (clés d'outils). L'URL Gitea
|
||||
# est volontairement absente : ce n'est pas un secret.
|
||||
CLES_MIGRABLES: tuple[tuple[str, str, str], ...] = (
|
||||
("DEEPSEEK_API_KEY", "api_key", "DeepSeek"),
|
||||
("OPENROUTER_API_KEY", "api_key", "OpenRouter"),
|
||||
("GEMINI_API_KEY", "api_key", "Gemini"),
|
||||
("NVIDIA_API_KEY", "api_key", "NVIDIA"),
|
||||
("QWENCLOUD_API_KEY", "api_key", "QwenCloud"),
|
||||
("XIAOMI_API_KEY", "api_key", "Xiaomi"),
|
||||
("MISTRAL_API_KEY", "api_key", "Mistral"),
|
||||
("OBSIGATE_TAVILY_API_KEY", "api_key", "Tavily"),
|
||||
("OBSIGATE_BRAVE_API_KEY", "api_key", "Brave"),
|
||||
("OBSIGATE_SERPAPI_API_KEY", "api_key", "SerpAPI"),
|
||||
("OBSIGATE_EXA_API_KEY", "api_key", "Exa"),
|
||||
("OBSIGATE_GITEA_TOKEN", "token", "Gitea"),
|
||||
("OBSIGATE_GITHUB_TOKEN", "token", "GitHub"),
|
||||
)
|
||||
|
||||
_NOMS_MIGRABLES = {nom for nom, _, _ in CLES_MIGRABLES}
|
||||
|
||||
|
||||
def _nom_secret(nom_source: str) -> str:
|
||||
"""Nom de secret dérivé du nom source (minuscules, valide NOM_SECRET_RE)."""
|
||||
return nom_source.lower()
|
||||
|
||||
|
||||
def _api_keys_path(base: Path) -> Path:
|
||||
return base / "api_keys.json"
|
||||
|
||||
|
||||
def _read_api_keys(base: Path) -> dict:
|
||||
path = _api_keys_path(base)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("api_keys.json illisible (%s) : %s", path, exc)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_api_keys(base: Path, data: dict) -> None:
|
||||
"""Écriture atomique tmp+rename, fichier final en 0600 (best-effort)."""
|
||||
path = _api_keys_path(base)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def lister_candidats(data_dir: str | Path | None = None) -> list:
|
||||
"""Aperçu de migration : métadonnées seules, jamais de valeur.
|
||||
|
||||
Chaque candidat porte ``nom`` (nom dans le coffre), ``source`` (nom
|
||||
dans api_keys.json), ``type``, ``service``, ``has_value`` (une valeur
|
||||
non vide est présente dans le fichier) et ``deja_existant`` (un
|
||||
secret de ce nom existe déjà dans le coffre d'instance).
|
||||
"""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
cles = _read_api_keys(base)
|
||||
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
|
||||
candidats = []
|
||||
for source, type_secret, service in CLES_MIGRABLES:
|
||||
nom = _nom_secret(source)
|
||||
valeur = cles.get(source)
|
||||
candidats.append(
|
||||
{
|
||||
"nom": nom,
|
||||
"source": source,
|
||||
"type": type_secret,
|
||||
"service": service,
|
||||
"has_value": isinstance(valeur, str) and bool(valeur.strip()),
|
||||
"deja_existant": (
|
||||
get_secret(coffre["id"], nom, base) is not None
|
||||
if coffre is not None
|
||||
else False
|
||||
),
|
||||
}
|
||||
)
|
||||
return candidats
|
||||
|
||||
|
||||
def importer_api_keys(
|
||||
data_dir: str | Path | None = None, cree_par: str = ""
|
||||
) -> dict:
|
||||
"""Importe les entrées avec valeur vers le coffre d'instance.
|
||||
|
||||
Ne supprime rien dans ``api_keys.json``. Retourne uniquement des
|
||||
noms et des comptes : ``migres``, ``deja_existants``, ``sans_valeur``.
|
||||
Lève ``KEKAbsenteError`` / ``CryptoIndisponibleError`` si le coffre
|
||||
ne peut pas chiffrer (le routeur les traduit en 503).
|
||||
"""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
cles = _read_api_keys(base)
|
||||
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
|
||||
if coffre is None:
|
||||
coffre = create_coffre(
|
||||
slug=COFFRE_INTERNE_SLUG,
|
||||
nom=COFFRE_INTERNE_NOM,
|
||||
proprietaire=(cree_par or "admin").strip(),
|
||||
perimetre="instance",
|
||||
description="Coffre d'instance : clés IA et clés d'outils migrées depuis api_keys.json (Phase 2).",
|
||||
data_dir=base,
|
||||
)
|
||||
migres: list[str] = []
|
||||
deja_existants: list[str] = []
|
||||
sans_valeur: list[str] = []
|
||||
for source, type_secret, service in CLES_MIGRABLES:
|
||||
nom = _nom_secret(source)
|
||||
valeur = cles.get(source)
|
||||
if not (isinstance(valeur, str) and valeur.strip()):
|
||||
sans_valeur.append(nom)
|
||||
continue
|
||||
if get_secret(coffre["id"], nom, base) is not None:
|
||||
deja_existants.append(nom)
|
||||
continue
|
||||
create_secret(
|
||||
coffre,
|
||||
nom,
|
||||
type_secret,
|
||||
valeur,
|
||||
service=service,
|
||||
note="Migré depuis api_keys.json (Phase 2).",
|
||||
data_dir=base,
|
||||
)
|
||||
migres.append(nom)
|
||||
return {
|
||||
"coffre": COFFRE_INTERNE_SLUG,
|
||||
"migres": migres,
|
||||
"deja_existants": deja_existants,
|
||||
"sans_valeur": sans_valeur,
|
||||
}
|
||||
|
||||
|
||||
def purger_api_keys(
|
||||
noms: list[str] | None,
|
||||
data_dir: str | Path | None = None,
|
||||
confirmer: bool = False,
|
||||
) -> dict:
|
||||
"""Supprime du clair les noms listés, après confirmation explicite.
|
||||
|
||||
``confirmer`` doit valoir True, sinon refus (jamais automatique).
|
||||
Seuls les noms migrables sont acceptés ; un nom inconnu refuse
|
||||
l'opération entière, pour ne jamais supprimer une clé par erreur.
|
||||
Retourne ``supprimes`` et ``absents`` (noms, jamais de valeurs).
|
||||
"""
|
||||
if not confirmer:
|
||||
raise ValidationError(
|
||||
"Suppression du clair refusée : confirmation explicite requise"
|
||||
)
|
||||
demandes = [(n or "").strip().lower() for n in (noms or [])]
|
||||
# Accepte indifféremment le nom source (DEEPSEEK_API_KEY) ou le nom
|
||||
# de secret (deepseek_api_key) ; tout le reste refuse l'opération.
|
||||
noms_secrets = {_nom_secret(source) for source in _NOMS_MIGRABLES}
|
||||
inconnus = [
|
||||
n
|
||||
for n in demandes
|
||||
if n.upper() not in _NOMS_MIGRABLES and n not in noms_secrets
|
||||
]
|
||||
if inconnus:
|
||||
raise ValidationError(f"Noms inconnus, purge annulée : {', '.join(inconnus)}")
|
||||
sources_a_supprimer = set()
|
||||
for demande in demandes:
|
||||
for source, _, _ in CLES_MIGRABLES:
|
||||
if demande in (source.lower(), _nom_secret(source)):
|
||||
sources_a_supprimer.add(source)
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
cles = _read_api_keys(base)
|
||||
supprimes: list[str] = []
|
||||
absents: list[str] = []
|
||||
for source in sorted(sources_a_supprimer):
|
||||
if source in cles:
|
||||
del cles[source]
|
||||
supprimes.append(_nom_secret(source))
|
||||
else:
|
||||
absents.append(_nom_secret(source))
|
||||
_write_api_keys(base, cles)
|
||||
return {"supprimes": supprimes, "absents": absents}
|
||||
|
||||
|
||||
def creer_consommateur_avec_octrois(
|
||||
nom: str,
|
||||
octrois_demandes: list[dict[str, Any]] | None,
|
||||
cree_par: str,
|
||||
data_dir: str | Path | None = None,
|
||||
) -> tuple[dict, str, list, list]:
|
||||
"""Enregistre un consommateur et ses octrois en une seule opération.
|
||||
|
||||
Retourne (consommateur public, jeton à usage unique, octrois créés,
|
||||
erreurs). Une erreur sur un octroi (coffre ou secret introuvable)
|
||||
n'annule pas le consommateur ni les autres octrois : elle est
|
||||
signalée dans ``erreurs`` (noms seuls, jamais de valeurs). Le coffre
|
||||
lui-même n'est jamais modifié par cette opération.
|
||||
"""
|
||||
from backend.coffre_store import ( # import local : évite un cycle
|
||||
IntrouvableError,
|
||||
create_consommateur,
|
||||
create_octroi,
|
||||
)
|
||||
|
||||
base = resolve_data_dir(data_dir)
|
||||
consommateur, jeton = create_consommateur(nom, cree_par, base)
|
||||
crees: list[dict] = []
|
||||
erreurs: list[dict] = []
|
||||
for demande in octrois_demandes or []:
|
||||
slug = str(demande.get("coffre_slug", "") or "")
|
||||
secret_nom = str(demande.get("secret_nom", "") or "") or None
|
||||
try:
|
||||
coffre = get_coffre(slug, base)
|
||||
if coffre is None:
|
||||
raise IntrouvableError(f"Coffre introuvable : {slug}")
|
||||
secret_id = None
|
||||
if secret_nom is not None:
|
||||
secret = get_secret(coffre["id"], secret_nom, base)
|
||||
if secret is None:
|
||||
raise IntrouvableError(f"Secret introuvable : {secret_nom}")
|
||||
secret_id = secret["id"]
|
||||
octroi = create_octroi(
|
||||
coffre=coffre,
|
||||
cible_type="consommateur",
|
||||
cible_id=consommateur["id"],
|
||||
secret_id=secret_id,
|
||||
cree_par=cree_par,
|
||||
expiration=demande.get("expiration"),
|
||||
data_dir=base,
|
||||
)
|
||||
crees.append(octroi)
|
||||
except (IntrouvableError, ValidationError) as exc:
|
||||
erreurs.append(
|
||||
{
|
||||
"coffre_slug": slug,
|
||||
"secret_nom": secret_nom,
|
||||
"erreur": str(exc),
|
||||
}
|
||||
)
|
||||
return consommateur, jeton, crees, erreurs
|
||||
@@ -0,0 +1,860 @@
|
||||
"""Store du coffre Secrets ObsiGate (Phase 1a).
|
||||
|
||||
Patron repris de ``backend/tools/secrets.py`` : JSON dans ``data/``,
|
||||
écriture atomique (tmp + rename), verrou ``RLock`` sur les cycles
|
||||
lecture-modification-écriture, répertoire honorant ``OBSIGATE_DATA_DIR``.
|
||||
|
||||
Fichiers (permissions 0600) :
|
||||
- ``data/coffres.json`` : coffres (métadonnées + DEK chiffrées),
|
||||
consommateurs (jetons hachés SHA-256 seulement, jamais en clair),
|
||||
octrois (références, statuts, expirations).
|
||||
- ``data/coffres_valeurs.json`` : versions chiffrées des secrets.
|
||||
|
||||
Aucune valeur en clair n'est jamais persistée ici, et aucune fonction de
|
||||
ce module ne journalise de valeur ni de texte chiffré.
|
||||
|
||||
Modèle :
|
||||
- Coffre : id, slug, nom, description, proprietaire, perimetre
|
||||
(personnel | partage | instance), dek_chiffree (ou None en mode
|
||||
dégradé sans KEK), statut (actif | archive), dates.
|
||||
- Secret : id, coffre_id, nom, type, service, note, expiration,
|
||||
version_courante, cree_le, modifie_le, derniere_utilisation.
|
||||
- Version : numero, nonce, ciphertext, cree_le, remplacee_le.
|
||||
Seule la version courante est distribuable.
|
||||
- Consommateur : id (sert de jti), nom, jeton_hash, jeton_prefixe,
|
||||
statut (actif | revoque), cree_par, cree_le.
|
||||
- Octroi : id, coffre_id, secret_id (None = tout le coffre),
|
||||
cible_type (consommateur | utilisateur | executeur | workspace),
|
||||
cible_id, expiration, statut (actif | revoque | expire),
|
||||
cree_par, cree_le, revoque_le.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets as _secrets
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.coffre_crypto import (
|
||||
decrypt_secret_value,
|
||||
encrypt_secret_value,
|
||||
generate_dek,
|
||||
load_kek,
|
||||
parse_kek,
|
||||
reencrypt_secret_value,
|
||||
unwrap_dek,
|
||||
wrap_dek,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.coffre_store")
|
||||
|
||||
_lock = threading.RLock()
|
||||
|
||||
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
|
||||
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
|
||||
TYPES_SECRET = {"api_key", "token", "password", "certificate", "autre"}
|
||||
PERIMETRES = {"personnel", "partage", "instance"}
|
||||
CIBLES_OCTROI = {"consommateur", "utilisateur", "executeur", "workspace"}
|
||||
|
||||
MAX_VALEUR = 65536
|
||||
MAX_TEXTE = 2000
|
||||
DUREE_OCTROI_DEFAUT_JOURS = 90
|
||||
|
||||
# Motifs refusés dans les champs de métadonnées (description, note) :
|
||||
# une valeur ressemblant à un secret ne doit jamais transiter par un champ
|
||||
# qui, lui, est renvoyé en clair dans les métadonnées.
|
||||
_SECRET_LIKE_PATTERNS = (
|
||||
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
||||
re.compile(r"\bsk-[A-Za-z0-9]{8,}\b"),
|
||||
re.compile(r"\bgh[opsu]_[A-Za-z0-9]{8,}\b"),
|
||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||
re.compile(
|
||||
r"(?i)\b(token|secret|mot_de_passe|password|api[-_]?key)\s*[:=]\s*\S+"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class StoreError(Exception):
|
||||
"""Erreur générique du store."""
|
||||
|
||||
|
||||
class CoffreExisteError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class SecretExisteError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class IntrouvableError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class SecretExpireError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_data_dir(data_dir: str | Path | None = None) -> Path:
|
||||
"""Répertoire de persistance (OBSIGATE_DATA_DIR, défaut ``data``)."""
|
||||
if data_dir is not None:
|
||||
return Path(data_dir)
|
||||
return Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
|
||||
|
||||
|
||||
def _coffres_path(base: Path) -> Path:
|
||||
return base / "coffres.json"
|
||||
|
||||
|
||||
def _valeurs_path(base: Path) -> Path:
|
||||
return base / "coffres_valeurs.json"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> datetime | None:
|
||||
"""Parse une date ISO (datetime) ou une date seule (fin de journée UTC)."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) == 10:
|
||||
text = text + "T23:59:59+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"Date invalide : {value!r}") from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
|
||||
|
||||
def est_expire(expiration: str | None, maintenant: datetime | None = None) -> bool:
|
||||
"""True si la date d'expiration est passée (None = jamais)."""
|
||||
parsed = _parse_date(expiration)
|
||||
if parsed is None:
|
||||
return False
|
||||
ref = maintenant or datetime.now(UTC)
|
||||
return parsed <= ref
|
||||
|
||||
|
||||
def _read_json(path: Path, default: dict) -> dict:
|
||||
if not path.exists():
|
||||
return json.loads(json.dumps(default))
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("Store coffre illisible (%s) : %s", path, exc)
|
||||
return json.loads(json.dumps(default))
|
||||
return data if isinstance(data, dict) else json.loads(json.dumps(default))
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict) -> None:
|
||||
"""Écriture atomique tmp+rename, fichier final en 0600."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_coffres(base: Path) -> dict:
|
||||
return _read_json(
|
||||
_coffres_path(base),
|
||||
{"coffres": [], "consommateurs": [], "octrois": []},
|
||||
)
|
||||
|
||||
|
||||
def _load_valeurs(base: Path) -> dict:
|
||||
return _read_json(_valeurs_path(base), {"versions": {}})
|
||||
|
||||
|
||||
def _save_coffres(base: Path, data: dict) -> None:
|
||||
_write_json(_coffres_path(base), data)
|
||||
|
||||
|
||||
def _save_valeurs(base: Path, data: dict) -> None:
|
||||
_write_json(_valeurs_path(base), data)
|
||||
|
||||
|
||||
def valider_slug(slug: str) -> str:
|
||||
value = (slug or "").strip().lower()
|
||||
if not SLUG_RE.match(value):
|
||||
raise ValidationError(
|
||||
"Slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def valider_nom_secret(nom: str) -> str:
|
||||
value = (nom or "").strip().lower()
|
||||
if not NOM_SECRET_RE.match(value):
|
||||
raise ValidationError(
|
||||
"Nom de secret invalide : 3 à 128 caractères, minuscules, chiffres, - ou _"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def refuser_valeur_dans_metadonnees(texte: str | None, champ: str) -> None:
|
||||
"""Refuse une valeur ressemblant à un secret dans un champ métadonnée."""
|
||||
if not texte:
|
||||
return
|
||||
for motif in _SECRET_LIKE_PATTERNS:
|
||||
if motif.search(texte):
|
||||
raise ValidationError(
|
||||
f"Le champ {champ} ressemble à une valeur de secret ; "
|
||||
"utilisez le champ valeur du secret, jamais une métadonnée"
|
||||
)
|
||||
|
||||
|
||||
def _valider_texte(texte: str | None, champ: str) -> str:
|
||||
value = (texte or "").strip()
|
||||
if len(value) > MAX_TEXTE:
|
||||
raise ValidationError(f"Le champ {champ} dépasse {MAX_TEXTE} caractères")
|
||||
refuser_valeur_dans_metadonnees(value, champ)
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coffres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_coffres(data_dir: str | Path | None = None) -> list:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
return list(_load_coffres(base)["coffres"])
|
||||
|
||||
|
||||
def get_coffre(slug: str, data_dir: str | Path | None = None) -> dict | None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
slug = valider_slug(slug)
|
||||
with _lock:
|
||||
for coffre in _load_coffres(base)["coffres"]:
|
||||
if coffre["slug"] == slug:
|
||||
return coffre
|
||||
return None
|
||||
|
||||
|
||||
def get_coffre_or_404(slug: str, data_dir: str | Path | None = None) -> dict:
|
||||
coffre = get_coffre(slug, data_dir)
|
||||
if coffre is None:
|
||||
raise IntrouvableError(f"Coffre introuvable : {slug}")
|
||||
return coffre
|
||||
|
||||
|
||||
def get_coffre_by_id(coffre_id: str, data_dir: str | Path | None = None) -> dict | None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
for coffre in _load_coffres(base)["coffres"]:
|
||||
if coffre["id"] == coffre_id:
|
||||
return coffre
|
||||
return None
|
||||
|
||||
|
||||
def get_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict | None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
for octroi in _load_coffres(base)["octrois"]:
|
||||
if octroi["id"] == octroi_id:
|
||||
return octroi
|
||||
return None
|
||||
|
||||
|
||||
def create_coffre(
|
||||
slug: str,
|
||||
nom: str,
|
||||
proprietaire: str,
|
||||
perimetre: str = "personnel",
|
||||
description: str = "",
|
||||
data_dir: str | Path | None = None,
|
||||
) -> dict:
|
||||
"""Crée un coffre. Sans KEK, la DEK reste None (mode dégradé) : les
|
||||
métadonnées sont créées, mais aucun secret ne pourra être ajouté tant
|
||||
que la KEK n'est pas configurée."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
slug = valider_slug(slug)
|
||||
if perimetre not in PERIMETRES:
|
||||
raise ValidationError(f"Périmètre invalide : {perimetre}")
|
||||
if not (proprietaire or "").strip():
|
||||
raise ValidationError("Propriétaire requis")
|
||||
description = _valider_texte(description, "description")
|
||||
nom = (nom or "").strip()
|
||||
if not nom or len(nom) > 128:
|
||||
raise ValidationError("Nom de coffre requis (128 caractères max)")
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
if any(c["slug"] == slug for c in data["coffres"]):
|
||||
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
|
||||
coffre: dict[str, Any] = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"slug": slug,
|
||||
"nom": nom,
|
||||
"description": description,
|
||||
"proprietaire": proprietaire.strip(),
|
||||
"perimetre": perimetre,
|
||||
"dek_chiffree": None,
|
||||
"statut": "actif",
|
||||
"cree_le": _now_iso(),
|
||||
}
|
||||
try:
|
||||
kek = load_kek()
|
||||
except Exception:
|
||||
kek = None
|
||||
if kek is not None:
|
||||
coffre["dek_chiffree"] = wrap_dek(generate_dek(), kek, coffre["id"])
|
||||
data["coffres"].append(coffre)
|
||||
_save_coffres(base, data)
|
||||
return coffre
|
||||
|
||||
|
||||
def ensure_dek(coffre: dict, data_dir: str | Path | None = None) -> bytes:
|
||||
"""Retourne la DEK en clair (mémoire seulement). Génère et persiste la
|
||||
DEK si le coffre a été créé en mode dégradé et que la KEK est là."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
kek = load_kek() # lève KEKAbsenteError si aucune KEK
|
||||
if coffre.get("dek_chiffree"):
|
||||
return unwrap_dek(coffre["dek_chiffree"], kek, coffre["id"])
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
for item in data["coffres"]:
|
||||
if item["id"] == coffre["id"]:
|
||||
if item.get("dek_chiffree"):
|
||||
return unwrap_dek(item["dek_chiffree"], kek, item["id"])
|
||||
dek = generate_dek()
|
||||
item["dek_chiffree"] = wrap_dek(dek, kek, item["id"])
|
||||
_save_coffres(base, data)
|
||||
return dek
|
||||
raise IntrouvableError("Coffre introuvable pour génération de DEK")
|
||||
|
||||
|
||||
# Les secrets vivent dans coffres.json (métadonnées), leurs versions dans
|
||||
# coffres_valeurs.json (chiffrées). Les deux chargements sont faits ici
|
||||
# pour garder une seule source de vérité dans ce module.
|
||||
def _load_tout(base: Path) -> tuple[dict, dict]:
|
||||
data = _load_coffres(base)
|
||||
if "secrets" not in data:
|
||||
data["secrets"] = []
|
||||
return data, _load_valeurs(base)
|
||||
|
||||
|
||||
def compter_secrets(coffre_id: str, data_dir: str | Path | None = None) -> int:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data, _ = _load_tout(base)
|
||||
return sum(1 for s in data["secrets"] if s["coffre_id"] == coffre_id)
|
||||
|
||||
|
||||
def list_secrets(coffre_id: str, data_dir: str | Path | None = None) -> list:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data, _ = _load_tout(base)
|
||||
return [s for s in data["secrets"] if s["coffre_id"] == coffre_id]
|
||||
|
||||
|
||||
def get_secret(
|
||||
coffre_id: str, nom: str, data_dir: str | Path | None = None
|
||||
) -> dict | None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = valider_nom_secret(nom)
|
||||
with _lock:
|
||||
data, _ = _load_tout(base)
|
||||
for secret in data["secrets"]:
|
||||
if secret["coffre_id"] == coffre_id and secret["nom"] == nom:
|
||||
return secret
|
||||
return None
|
||||
|
||||
|
||||
def secret_metadata(secret: dict, octrois_actifs: int = 0) -> dict:
|
||||
"""Vue métadonnées seules d'un secret : jamais de valeur, jamais de
|
||||
texte chiffré, jamais de nonce."""
|
||||
return {
|
||||
"id": secret["id"],
|
||||
"nom": secret["nom"],
|
||||
"type": secret["type"],
|
||||
"service": secret.get("service", ""),
|
||||
"note": secret.get("note", ""),
|
||||
"expiration": secret.get("expiration"),
|
||||
"expire": est_expire(secret.get("expiration")),
|
||||
"version": secret["version_courante"],
|
||||
"has_value": True,
|
||||
"cree_le": secret["cree_le"],
|
||||
"modifie_le": secret["modifie_le"],
|
||||
"derniere_utilisation": secret.get("derniere_utilisation"),
|
||||
"octrois_actifs": octrois_actifs,
|
||||
"reference": f"coffre:{secret['coffre_slug']}/{secret['nom']}",
|
||||
}
|
||||
|
||||
|
||||
def create_secret(
|
||||
coffre: dict,
|
||||
nom: str,
|
||||
type_secret: str,
|
||||
valeur: str,
|
||||
service: str = "",
|
||||
expiration: str | None = None,
|
||||
note: str = "",
|
||||
data_dir: str | Path | None = None,
|
||||
) -> dict:
|
||||
"""Chiffre et stocke un secret (nouvelle version 1)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = valider_nom_secret(nom)
|
||||
if type_secret not in TYPES_SECRET:
|
||||
raise ValidationError(f"Type de secret invalide : {type_secret}")
|
||||
if not valeur or len(valeur) > MAX_VALEUR:
|
||||
raise ValidationError("Valeur requise (64 KiB max)")
|
||||
service = (service or "").strip()
|
||||
if len(service) > 128:
|
||||
raise ValidationError("Service trop long (128 caractères max)")
|
||||
note = _valider_texte(note, "note")
|
||||
if expiration is not None:
|
||||
_parse_date(expiration)
|
||||
dek = ensure_dek(coffre, base) # KEKAbsenteError si pas de KEK
|
||||
with _lock:
|
||||
data, valeurs = _load_tout(base)
|
||||
if any(
|
||||
s["coffre_id"] == coffre["id"] and s["nom"] == nom
|
||||
for s in data["secrets"]
|
||||
):
|
||||
raise SecretExisteError(f"Secret déjà existant : {nom}")
|
||||
secret_id = str(uuid.uuid4())
|
||||
chiffre = encrypt_secret_value(dek, secret_id, 1, valeur)
|
||||
secret = {
|
||||
"id": secret_id,
|
||||
"coffre_id": coffre["id"],
|
||||
"coffre_slug": coffre["slug"],
|
||||
"nom": nom,
|
||||
"type": type_secret,
|
||||
"service": service,
|
||||
"note": note,
|
||||
"expiration": expiration,
|
||||
"version_courante": 1,
|
||||
"cree_le": _now_iso(),
|
||||
"modifie_le": _now_iso(),
|
||||
"derniere_utilisation": None,
|
||||
}
|
||||
data["secrets"].append(secret)
|
||||
valeurs["versions"][secret_id] = [
|
||||
{
|
||||
"numero": 1,
|
||||
"nonce": chiffre["nonce"],
|
||||
"ciphertext": chiffre["ciphertext"],
|
||||
"cree_le": secret["cree_le"],
|
||||
"remplacee_le": None,
|
||||
}
|
||||
]
|
||||
_save_coffres(base, data)
|
||||
_save_valeurs(base, valeurs)
|
||||
return secret
|
||||
|
||||
|
||||
def rotate_secret(
|
||||
coffre: dict, nom: str, valeur: str, data_dir: str | Path | None = None
|
||||
) -> dict:
|
||||
"""Rotation : nouvelle version active immédiatement, l'ancienne n'est
|
||||
plus distribuable (seule la version courante est lue à la distribution)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = valider_nom_secret(nom)
|
||||
if not valeur or len(valeur) > MAX_VALEUR:
|
||||
raise ValidationError("Valeur requise (64 KiB max)")
|
||||
dek = ensure_dek(coffre, base)
|
||||
with _lock:
|
||||
data, valeurs = _load_tout(base)
|
||||
secret = None
|
||||
for item in data["secrets"]:
|
||||
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
|
||||
secret = item
|
||||
break
|
||||
if secret is None:
|
||||
raise IntrouvableError(f"Secret introuvable : {nom}")
|
||||
nouvelle_version = secret["version_courante"] + 1
|
||||
chiffre = encrypt_secret_value(dek, secret["id"], nouvelle_version, valeur)
|
||||
versions = valeurs["versions"].setdefault(secret["id"], [])
|
||||
for version in versions:
|
||||
if version["remplacee_le"] is None:
|
||||
version["remplacee_le"] = _now_iso()
|
||||
versions.append(
|
||||
{
|
||||
"numero": nouvelle_version,
|
||||
"nonce": chiffre["nonce"],
|
||||
"ciphertext": chiffre["ciphertext"],
|
||||
"cree_le": _now_iso(),
|
||||
"remplacee_le": None,
|
||||
}
|
||||
)
|
||||
secret["version_courante"] = nouvelle_version
|
||||
secret["modifie_le"] = _now_iso()
|
||||
_save_coffres(base, data)
|
||||
_save_valeurs(base, valeurs)
|
||||
return secret
|
||||
|
||||
|
||||
def delete_secret(
|
||||
coffre: dict, nom: str, data_dir: str | Path | None = None
|
||||
) -> tuple[dict, list]:
|
||||
"""Supprime un secret, ses versions et ses octrois (cascade)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = valider_nom_secret(nom)
|
||||
with _lock:
|
||||
data, valeurs = _load_tout(base)
|
||||
secret = None
|
||||
for item in data["secrets"]:
|
||||
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
|
||||
secret = item
|
||||
break
|
||||
if secret is None:
|
||||
raise IntrouvableError(f"Secret introuvable : {nom}")
|
||||
data["secrets"] = [s for s in data["secrets"] if s["id"] != secret["id"]]
|
||||
valeurs["versions"].pop(secret["id"], None)
|
||||
octrois_supprimes = [
|
||||
o for o in data["octrois"] if o.get("secret_id") == secret["id"]
|
||||
]
|
||||
data["octrois"] = [
|
||||
o for o in data["octrois"] if o.get("secret_id") != secret["id"]
|
||||
]
|
||||
_save_coffres(base, data)
|
||||
_save_valeurs(base, valeurs)
|
||||
return secret, octrois_supprimes
|
||||
|
||||
|
||||
def distribuer_valeur(
|
||||
coffre: dict, secret: dict, data_dir: str | Path | None = None
|
||||
) -> str:
|
||||
"""Déchiffre la version courante d'un secret (après contrôles d'octroi
|
||||
faits par le routeur). Refuse un secret expiré (410 côté routeur)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
if est_expire(secret.get("expiration")):
|
||||
raise SecretExpireError("Secret expiré")
|
||||
dek = ensure_dek(coffre, base)
|
||||
with _lock:
|
||||
_, valeurs = _load_tout(base)
|
||||
versions = valeurs["versions"].get(secret["id"], [])
|
||||
courante = None
|
||||
for version in versions:
|
||||
if version["numero"] == secret["version_courante"]:
|
||||
courante = version
|
||||
break
|
||||
if courante is None:
|
||||
raise IntrouvableError("Version courante introuvable")
|
||||
return decrypt_secret_value(
|
||||
dek,
|
||||
secret["id"],
|
||||
secret["version_courante"],
|
||||
courante["nonce"],
|
||||
courante["ciphertext"],
|
||||
)
|
||||
|
||||
|
||||
def toucher_utilisation(secret_id: str, data_dir: str | Path | None = None) -> None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data, _ = _load_tout(base)
|
||||
for secret in data["secrets"]:
|
||||
if secret["id"] == secret_id:
|
||||
secret["derniere_utilisation"] = _now_iso()
|
||||
_save_coffres(base, data)
|
||||
return
|
||||
|
||||
|
||||
def rotate_dek(coffre_slug: str, data_dir: str | Path | None = None) -> dict:
|
||||
"""Rotation DEK : re-chiffre toutes les versions du coffre avec une
|
||||
nouvelle DEK, puis remplace la DEK chiffrée du coffre."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
coffre = get_coffre_or_404(coffre_slug, base)
|
||||
kek = load_kek()
|
||||
ancienne_dek = ensure_dek(coffre, base)
|
||||
nouvelle_dek = generate_dek()
|
||||
with _lock:
|
||||
data, valeurs = _load_tout(base)
|
||||
secrets_coffre = [s for s in data["secrets"] if s["coffre_id"] == coffre["id"]]
|
||||
for secret in secrets_coffre:
|
||||
nouvelles_versions = []
|
||||
for version in valeurs["versions"].get(secret["id"], []):
|
||||
nouvelles_versions.append(
|
||||
{
|
||||
**reencrypt_secret_value(
|
||||
ancienne_dek,
|
||||
nouvelle_dek,
|
||||
secret["id"],
|
||||
version["numero"],
|
||||
version["nonce"],
|
||||
version["ciphertext"],
|
||||
),
|
||||
"numero": version["numero"],
|
||||
"cree_le": version["cree_le"],
|
||||
"remplacee_le": version["remplacee_le"],
|
||||
}
|
||||
)
|
||||
valeurs["versions"][secret["id"]] = nouvelles_versions
|
||||
for item in data["coffres"]:
|
||||
if item["id"] == coffre["id"]:
|
||||
item["dek_chiffree"] = wrap_dek(nouvelle_dek, kek, item["id"])
|
||||
coffre = item
|
||||
break
|
||||
_save_coffres(base, data)
|
||||
_save_valeurs(base, valeurs)
|
||||
return coffre
|
||||
|
||||
|
||||
def rotate_kek(nouvelle_kek_b64: str, data_dir: str | Path | None = None) -> int:
|
||||
"""Rotation KEK : re-chiffre uniquement les DEK avec la nouvelle KEK.
|
||||
L'opérateur doit ensuite remplacer OBSIGATE_KEK / OBSIGATE_KEK_FILE
|
||||
par la nouvelle valeur (cette fonction ne touche pas à l'environnement).
|
||||
Retourne le nombre de coffres re-chiffrés."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
ancienne_kek = load_kek()
|
||||
nouvelle_kek = parse_kek(nouvelle_kek_b64)
|
||||
count = 0
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
for coffre in data["coffres"]:
|
||||
if not coffre.get("dek_chiffree"):
|
||||
continue
|
||||
dek = unwrap_dek(coffre["dek_chiffree"], ancienne_kek, coffre["id"])
|
||||
coffre["dek_chiffree"] = wrap_dek(dek, nouvelle_kek, coffre["id"])
|
||||
count += 1
|
||||
_save_coffres(base, data)
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consommateurs (jetons serveur-à-serveur, hachés, révocables par jti)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _hash_jeton(jeton: str) -> str:
|
||||
return hashlib.sha256(jeton.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def list_consommateurs(data_dir: str | Path | None = None) -> list:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
return [
|
||||
{k: v for k, v in c.items() if k != "jeton_hash"}
|
||||
for c in _load_coffres(base)["consommateurs"]
|
||||
]
|
||||
|
||||
|
||||
def create_consommateur(
|
||||
nom: str, cree_par: str, data_dir: str | Path | None = None
|
||||
) -> tuple[dict, str]:
|
||||
"""Crée un consommateur et retourne (métadonnées, jeton à usage unique).
|
||||
Le jeton n'est JAMAIS persisté : seul son hash SHA-256 est stocké."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = (nom or "").strip()
|
||||
if not nom or len(nom) > 128:
|
||||
raise ValidationError("Nom de consommateur requis (128 caractères max)")
|
||||
jeton = "csk_" + _secrets.token_urlsafe(32)
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
consommateur = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"nom": nom,
|
||||
"jeton_hash": _hash_jeton(jeton),
|
||||
"jeton_prefixe": jeton[:12],
|
||||
"statut": "actif",
|
||||
"cree_par": cree_par,
|
||||
"cree_le": _now_iso(),
|
||||
}
|
||||
data["consommateurs"].append(consommateur)
|
||||
_save_coffres(base, data)
|
||||
public = {k: v for k, v in consommateur.items() if k != "jeton_hash"}
|
||||
return public, jeton
|
||||
|
||||
|
||||
def verifier_jeton_consommateur(
|
||||
jeton: str, data_dir: str | Path | None = None
|
||||
) -> dict | None:
|
||||
"""Retourne le consommateur (sans hash) si le jeton correspond, sinon None."""
|
||||
if not jeton:
|
||||
return None
|
||||
base = resolve_data_dir(data_dir)
|
||||
cible = _hash_jeton(jeton)
|
||||
with _lock:
|
||||
for consommateur in _load_coffres(base)["consommateurs"]:
|
||||
if hmac.compare_digest(consommateur.get("jeton_hash", ""), cible):
|
||||
return {
|
||||
k: v for k, v in consommateur.items() if k != "jeton_hash"
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def revoquer_consommateur(
|
||||
consommateur_id: str, data_dir: str | Path | None = None
|
||||
) -> dict:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
for consommateur in data["consommateurs"]:
|
||||
if consommateur["id"] == consommateur_id:
|
||||
consommateur["statut"] = "revoque"
|
||||
_save_coffres(base, data)
|
||||
return {
|
||||
k: v
|
||||
for k, v in consommateur.items()
|
||||
if k != "jeton_hash"
|
||||
}
|
||||
raise IntrouvableError("Consommateur introuvable")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Octrois
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_octrois(coffre_id: str, data_dir: str | Path | None = None) -> list:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
return [
|
||||
o for o in _load_coffres(base)["octrois"] if o["coffre_id"] == coffre_id
|
||||
]
|
||||
|
||||
|
||||
def compter_octrois_actifs(
|
||||
secret_id: str, data_dir: str | Path | None = None
|
||||
) -> int:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
return sum(
|
||||
1
|
||||
for o in _load_coffres(base)["octrois"]
|
||||
if o.get("secret_id") == secret_id
|
||||
and o["statut"] == "actif"
|
||||
and not est_expire(o.get("expiration"))
|
||||
)
|
||||
|
||||
|
||||
def create_octroi(
|
||||
coffre: dict,
|
||||
cible_type: str,
|
||||
cible_id: str,
|
||||
secret_id: str | None,
|
||||
cree_par: str,
|
||||
expiration: str | None = None,
|
||||
data_dir: str | Path | None = None,
|
||||
) -> dict:
|
||||
base = resolve_data_dir(data_dir)
|
||||
if cible_type not in CIBLES_OCTROI:
|
||||
raise ValidationError(f"Type de cible invalide : {cible_type}")
|
||||
if not (cible_id or "").strip():
|
||||
raise ValidationError("Cible d'octroi requise")
|
||||
if secret_id is not None:
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
if not any(
|
||||
s["id"] == secret_id and s["coffre_id"] == coffre["id"]
|
||||
for s in data["secrets"]
|
||||
):
|
||||
raise IntrouvableError("Secret introuvable dans ce coffre")
|
||||
if expiration is None:
|
||||
expiration = (datetime.now(UTC) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat()
|
||||
else:
|
||||
_parse_date(expiration)
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
octroi = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"coffre_id": coffre["id"],
|
||||
"secret_id": secret_id,
|
||||
"cible_type": cible_type,
|
||||
"cible_id": cible_id.strip(),
|
||||
"expiration": expiration,
|
||||
"statut": "actif",
|
||||
"cree_par": cree_par,
|
||||
"cree_le": _now_iso(),
|
||||
"revoque_le": None,
|
||||
}
|
||||
data["octrois"].append(octroi)
|
||||
_save_coffres(base, data)
|
||||
return octroi
|
||||
|
||||
|
||||
def revoquer_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict:
|
||||
"""Révocation : statut -> revoque (conservé pour distinguer
|
||||
octroi_revoque de octroi_absent à la distribution)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
for octroi in data["octrois"]:
|
||||
if octroi["id"] == octroi_id:
|
||||
octroi["statut"] = "revoque"
|
||||
octroi["revoque_le"] = _now_iso()
|
||||
_save_coffres(base, data)
|
||||
return octroi
|
||||
raise IntrouvableError("Octroi introuvable")
|
||||
|
||||
|
||||
def statut_octroi_consommateur(
|
||||
consommateur_id: str,
|
||||
coffre_id: str,
|
||||
secret_id: str,
|
||||
data_dir: str | Path | None = None,
|
||||
) -> tuple[str, dict | None]:
|
||||
"""Statut d'accès d'un consommateur à un secret.
|
||||
|
||||
Retourne ("ok" | "revoque" | "expire" | "absent", octroi). Un octroi
|
||||
coffre-wide (secret_id None) couvre tous les secrets du coffre.
|
||||
Priorité : ok > revoque > expire > absent (un octroi révoqué ne doit
|
||||
jamais être masqué par l'absence d'un autre octroi)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
candidats = [
|
||||
o
|
||||
for o in _load_coffres(base)["octrois"]
|
||||
if o["cible_type"] == "consommateur"
|
||||
and o["cible_id"] == consommateur_id
|
||||
and o["coffre_id"] == coffre_id
|
||||
and (o.get("secret_id") is None or o["secret_id"] == secret_id)
|
||||
]
|
||||
for octroi in candidats:
|
||||
if octroi["statut"] == "actif" and not est_expire(octroi.get("expiration")):
|
||||
return "ok", octroi
|
||||
for octroi in candidats:
|
||||
if octroi["statut"] == "revoque":
|
||||
return "revoque", octroi
|
||||
for octroi in candidats:
|
||||
if octroi["statut"] == "actif" and est_expire(octroi.get("expiration")):
|
||||
return "expire", octroi
|
||||
return "absent", None
|
||||
|
||||
|
||||
def secrets_accessibles_utilisateur(
|
||||
username: str, coffre: dict, data_dir: str | Path | None = None
|
||||
) -> set | None:
|
||||
"""Secrets d'un coffre visibles par un utilisateur via octroi.
|
||||
|
||||
Retourne None si un octroi coffre-wide actif existe (tout le coffre),
|
||||
sinon l'ensemble des ids de secrets octroyés, sinon l'ensemble vide."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
octrois = [
|
||||
o
|
||||
for o in _load_coffres(base)["octrois"]
|
||||
if o["cible_type"] == "utilisateur"
|
||||
and o["cible_id"] == username
|
||||
and o["coffre_id"] == coffre["id"]
|
||||
and o["statut"] == "actif"
|
||||
and not est_expire(o.get("expiration"))
|
||||
]
|
||||
if any(o.get("secret_id") is None for o in octrois):
|
||||
return None
|
||||
return {o["secret_id"] for o in octrois if o.get("secret_id")}
|
||||
+2
-2
@@ -359,7 +359,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
||||
|
||||
rendered = _render_body(body, path.parent, vault_path, path)
|
||||
|
||||
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
|
||||
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.UTC).timestamp())}"
|
||||
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
|
||||
@@ -383,7 +383,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
||||
<dc:title>{html_mod.escape(title)}</dc:title>
|
||||
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
||||
<dc:language>fr</dc:language>
|
||||
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
||||
<meta property="dcterms:modified">{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
|
||||
@@ -355,7 +355,7 @@ def _export_footer(lang: str) -> str:
|
||||
template = loc.get("guide105.export_footer", "")
|
||||
if "%s" not in template and "{" not in template:
|
||||
template = "ObsiGate {version}"
|
||||
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
|
||||
today = datetime.datetime.now(tz=datetime.UTC).date().isoformat()
|
||||
return _collapse(template).format(version=_app_version(), date=today)
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@ import os
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -581,7 +581,7 @@ def _scan_vault(
|
||||
try:
|
||||
relative = fpath.relative_to(vault_root)
|
||||
stat = fpath.stat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
|
||||
|
||||
# #86 differential scan: reuse the previous entry when neither
|
||||
# size nor mtime changed — skips the disk read + parse below.
|
||||
@@ -866,7 +866,7 @@ async def build_index(progress_callback=None) -> None:
|
||||
|
||||
# Record timestamp of full index rebuild
|
||||
global _last_full_index_ts
|
||||
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
|
||||
_last_full_index_ts = datetime.now(UTC).isoformat()
|
||||
|
||||
# Build attachment index
|
||||
from backend.attachment_indexer import build_attachment_index
|
||||
@@ -1033,7 +1033,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
return None
|
||||
|
||||
stat = fpath.stat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
|
||||
|
||||
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
|
||||
tags: list[str] = []
|
||||
|
||||
@@ -530,6 +530,7 @@ from backend.auth.middleware import (
|
||||
from backend.auth.router import router as auth_router
|
||||
from backend.bookslm_routes import router as bookslm_router
|
||||
from backend.routers.backups import router as backups_router
|
||||
from backend.routers.coffres import router as coffres_router
|
||||
from backend.routers.config import _load_config
|
||||
from backend.routers.config import router as config_router
|
||||
from backend.routers.conflicts import router as conflicts_router
|
||||
@@ -564,6 +565,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
||||
app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py
|
||||
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
|
||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
||||
|
||||
+4
-4
@@ -25,7 +25,7 @@ import os
|
||||
import smtplib
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -182,7 +182,7 @@ def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict
|
||||
"type": channel_type,
|
||||
"enabled": True,
|
||||
"config": normalized,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"last_sent_at": None,
|
||||
"last_error": None,
|
||||
}
|
||||
@@ -287,7 +287,7 @@ def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
|
||||
"event": trigger,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"source": "obsigate-notify",
|
||||
},
|
||||
timeout=10.0,
|
||||
@@ -345,6 +345,6 @@ def _mark_sent(channel_id: str, error: str | None) -> None:
|
||||
channels = _read_channels()
|
||||
for channel in channels:
|
||||
if channel.get("id") == channel_id:
|
||||
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
|
||||
channel["last_sent_at"] = datetime.now(UTC).isoformat()
|
||||
channel["last_error"] = error
|
||||
_write_channels(channels)
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -185,7 +185,7 @@ async def subscribe_push(
|
||||
"keys": request.subscription.keys,
|
||||
"vault": request.vault,
|
||||
"username": username,
|
||||
"created_at": datetime.now(timezone.utc).isoformat()
|
||||
"created_at": datetime.now(UTC).isoformat()
|
||||
}
|
||||
|
||||
_push_subscriptions.append(subscription)
|
||||
|
||||
@@ -73,3 +73,9 @@ filelock>=4.2.1
|
||||
# aucune dépendance directe d'ObsiGate) — CVE-2026-114603, corrigée
|
||||
# en 1.45.1.
|
||||
opentelemetry-api>=1.45.1
|
||||
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
|
||||
cryptography>=44.0
|
||||
|
||||
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
|
||||
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
|
||||
msgpack>=1.2.3
|
||||
|
||||
@@ -19,7 +19,7 @@ Adaptations strictement équivalentes :
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -207,7 +207,7 @@ async def api_backups_list(
|
||||
"file": rel_file,
|
||||
"backup_file": fpath.name,
|
||||
"timestamp": ts,
|
||||
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
|
||||
"datetime": datetime.fromtimestamp(ts, tz=UTC).isoformat(),
|
||||
"size": fsize,
|
||||
"full_path": str(fpath),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
"""API du coffre Secrets ObsiGate (Phase 1b, spec §7, §10, §11).
|
||||
|
||||
Monté dans ``backend/main.py`` à côté de ``config_router`` — JAMAIS dans
|
||||
``backend/routers/vaults.py`` : un coffre n'est pas un vault de notes, il
|
||||
ne passe ni par l'indexeur, ni par la recherche, ni par le graphe, et
|
||||
aucun outil MCP de secret n'existe.
|
||||
|
||||
Règles d'or, appliquées côté serveur à chaque requête :
|
||||
- Toutes les lectures renvoient des métadonnées seules (``has_value``,
|
||||
version, dates, octrois actifs) : jamais de valeur, jamais de texte
|
||||
chiffré, jamais de nonce.
|
||||
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
|
||||
un consommateur authentifié par jeton (hash + jti, jamais la session
|
||||
utilisateur), avec un octroi actif et un contexte obligatoire.
|
||||
- Autorisations (spec §6) : le propriétaire seul sur son coffre
|
||||
personnel ; l'admin gère les coffres d'instance, les consommateurs et
|
||||
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
|
||||
ne donne AUCUN droit sur un coffre (``check_vault_access`` n'est pas
|
||||
utilisé ici, volontairement).
|
||||
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
||||
qu'il n'est pas activé, toutes les routes répondent 503.
|
||||
- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre
|
||||
d'instance, en deux temps (import, puis purge confirmée), et
|
||||
enregistrement d'un consommateur avec ses octrois en un appel.
|
||||
- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à
|
||||
plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes
|
||||
d'erreur restent ceux de la Phase 1b (voir
|
||||
``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec
|
||||
l'état ``acces_requis`` côté Spark).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
|
||||
from backend.audit import get_recent_entries, log_coffre_event
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.coffre_crypto import (
|
||||
CryptoIndisponibleError,
|
||||
KEKAbsenteError,
|
||||
generate_kek_b64,
|
||||
)
|
||||
from backend.coffre_migration import (
|
||||
COFFRE_INTERNE_SLUG,
|
||||
creer_consommateur_avec_octrois,
|
||||
importer_api_keys,
|
||||
lister_candidats,
|
||||
purger_api_keys,
|
||||
)
|
||||
from backend.coffre_store import (
|
||||
CoffreExisteError,
|
||||
IntrouvableError,
|
||||
SecretExisteError,
|
||||
SecretExpireError,
|
||||
ValidationError,
|
||||
compter_octrois_actifs,
|
||||
compter_secrets,
|
||||
create_coffre,
|
||||
create_consommateur,
|
||||
create_octroi,
|
||||
create_secret,
|
||||
delete_secret,
|
||||
distribuer_valeur,
|
||||
get_coffre,
|
||||
get_coffre_by_id,
|
||||
get_coffre_or_404,
|
||||
get_octroi,
|
||||
get_secret,
|
||||
list_coffres,
|
||||
list_consommateurs,
|
||||
list_octrois,
|
||||
list_secrets,
|
||||
revoquer_consommateur,
|
||||
revoquer_octroi,
|
||||
rotate_dek,
|
||||
rotate_kek,
|
||||
rotate_secret,
|
||||
secret_metadata,
|
||||
secrets_accessibles_utilisateur,
|
||||
statut_octroi_consommateur,
|
||||
toucher_utilisation,
|
||||
verifier_jeton_consommateur,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.coffres")
|
||||
|
||||
router = APIRouter(tags=["Coffres Secrets"])
|
||||
|
||||
CODES_ERREUR = {
|
||||
"octroi_absent",
|
||||
"octroi_revoque",
|
||||
"octroi_expire",
|
||||
"secret_expire",
|
||||
"kek_absente",
|
||||
"contexte_manquant",
|
||||
"consommateur_invalide",
|
||||
"consommateur_revoque",
|
||||
"crypto_indisponible",
|
||||
"secrets_desactives",
|
||||
}
|
||||
|
||||
|
||||
def secrets_actives() -> bool:
|
||||
"""Feature flag : défaut désactivé, activation explicite par l'opérateur."""
|
||||
return os.environ.get("OBSIGATE_SECRETS_ENABLED", "false").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def _exiger_actives() -> None:
|
||||
if not secrets_actives():
|
||||
raise HTTPException(status_code=503, detail="secrets_desactives")
|
||||
|
||||
|
||||
def _username(user: dict) -> str:
|
||||
return str(user.get("username", ""))
|
||||
|
||||
|
||||
def _est_admin(user: dict) -> bool:
|
||||
return user.get("role") == "admin"
|
||||
|
||||
|
||||
def _peut_voir_coffre(user: dict, coffre: dict) -> bool:
|
||||
"""Voir les métadonnées d'un coffre."""
|
||||
if coffre["proprietaire"] == _username(user):
|
||||
return True
|
||||
if _est_admin(user) and coffre["perimetre"] == "instance":
|
||||
return True
|
||||
# Octroi utilisateur (coffre-wide ou par secret) : accès en lecture.
|
||||
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
|
||||
if accessibles is None:
|
||||
return True
|
||||
return len(accessibles) > 0
|
||||
|
||||
|
||||
def _peut_gerer_coffre(user: dict, coffre: dict) -> bool:
|
||||
"""Créer / roter / supprimer des secrets et gérer les octrois."""
|
||||
if coffre["proprietaire"] == _username(user):
|
||||
return True
|
||||
return _est_admin(user) and coffre["perimetre"] == "instance"
|
||||
|
||||
|
||||
def _exiger_voir(user: dict, coffre: dict) -> None:
|
||||
if not _peut_voir_coffre(user, coffre):
|
||||
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
|
||||
|
||||
|
||||
def _exiger_gerer(user: dict, coffre: dict) -> None:
|
||||
if not _peut_gerer_coffre(user, coffre):
|
||||
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
|
||||
|
||||
|
||||
def _coffre_or_404(slug: str) -> dict:
|
||||
try:
|
||||
return get_coffre_or_404(slug)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _secret_or_404(coffre: dict, nom: str) -> dict:
|
||||
try:
|
||||
secret = get_secret(coffre["id"], nom)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if secret is None:
|
||||
raise HTTPException(status_code=404, detail=f"Secret introuvable : {nom}")
|
||||
return secret
|
||||
|
||||
|
||||
def _metadata_coffre(coffre: dict) -> dict:
|
||||
return {
|
||||
"slug": coffre["slug"],
|
||||
"nom": coffre["nom"],
|
||||
"description": coffre.get("description", ""),
|
||||
"proprietaire": coffre["proprietaire"],
|
||||
"perimetre": coffre["perimetre"],
|
||||
"statut": coffre["statut"],
|
||||
"kek_configuree": coffre.get("dek_chiffree") is not None,
|
||||
"nb_secrets": compter_secrets(coffre["id"]),
|
||||
"cree_le": coffre["cree_le"],
|
||||
}
|
||||
|
||||
|
||||
def _metadata_secret(coffre: dict, secret: dict) -> dict:
|
||||
return secret_metadata(secret, compter_octrois_actifs(secret["id"]))
|
||||
|
||||
|
||||
def _secrets_visibles(user: dict, coffre: dict) -> list:
|
||||
"""Secrets visibles par l'utilisateur (métadonnées seules)."""
|
||||
if coffre["proprietaire"] == _username(user) or (
|
||||
_est_admin(user) and coffre["perimetre"] == "instance"
|
||||
):
|
||||
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
|
||||
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
|
||||
if accessibles is None:
|
||||
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
|
||||
return [
|
||||
_metadata_secret(coffre, s)
|
||||
for s in list_secrets(coffre["id"])
|
||||
if s["id"] in accessibles
|
||||
]
|
||||
|
||||
|
||||
def _audit(user: str, action: str, **kwargs) -> None:
|
||||
try:
|
||||
log_coffre_event(user, action, **kwargs)
|
||||
except Exception as exc: # l'audit ne doit jamais casser l'opération
|
||||
logger.warning("Échec audit coffre (%s) : %s", action, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coffres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/coffres")
|
||||
async def api_list_coffres(current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
visibles = [c for c in list_coffres() if _peut_voir_coffre(current_user, c)]
|
||||
return [_metadata_coffre(c) for c in visibles]
|
||||
|
||||
|
||||
@router.post("/api/coffres")
|
||||
async def api_create_coffre(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
slug = body.get("slug", "")
|
||||
perimetre = body.get("perimetre", "personnel")
|
||||
if perimetre == "instance" and not _est_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Seul un admin peut créer un coffre d'instance"
|
||||
)
|
||||
try:
|
||||
coffre = create_coffre(
|
||||
slug=slug,
|
||||
nom=body.get("nom", ""),
|
||||
proprietaire=_username(current_user),
|
||||
perimetre=perimetre,
|
||||
description=body.get("description", ""),
|
||||
)
|
||||
except CoffreExisteError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"coffre_cree",
|
||||
coffre=coffre["slug"],
|
||||
resultat="ok",
|
||||
)
|
||||
return _metadata_coffre(coffre)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consommateurs (admin seulement, jeton rendu une seule fois)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/coffres/consommateurs")
|
||||
async def api_list_consommateurs(current_user=Depends(require_admin)):
|
||||
_exiger_actives()
|
||||
return list_consommateurs()
|
||||
|
||||
|
||||
@router.post("/api/coffres/consommateurs")
|
||||
async def api_create_consommateur(
|
||||
body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
_exiger_actives()
|
||||
try:
|
||||
consommateur, jeton = create_consommateur(
|
||||
nom=body.get("nom", ""), cree_par=_username(current_user)
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"consommateur_cree",
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="ok",
|
||||
)
|
||||
# Le jeton n'est rendu qu'ici, jamais stocké en clair, jamais relisté.
|
||||
return {**consommateur, "jeton": jeton}
|
||||
|
||||
|
||||
@router.post("/api/coffres/consommateurs/avec-octrois")
|
||||
async def api_create_consommateur_avec_octrois(
|
||||
body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
"""Phase 4 : enregistrer une app = un consommateur + ses octrois.
|
||||
|
||||
Le coffre n'est jamais modifié par cette opération. Le jeton n'est
|
||||
rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en
|
||||
erreur (coffre ou secret introuvable) est signalé dans ``erreurs``
|
||||
sans annuler le consommateur ni les autres octrois.
|
||||
"""
|
||||
_exiger_actives()
|
||||
try:
|
||||
consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois(
|
||||
nom=body.get("nom", ""),
|
||||
octrois_demandes=body.get("octrois") or [],
|
||||
cree_par=_username(current_user),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"consommateur_cree",
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="ok",
|
||||
)
|
||||
for octroi in octrois:
|
||||
coffre_octroi = get_coffre_by_id(octroi["coffre_id"])
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"octroi_cree",
|
||||
coffre=coffre_octroi["slug"] if coffre_octroi else None,
|
||||
resultat="ok",
|
||||
)
|
||||
return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs}
|
||||
|
||||
|
||||
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
|
||||
async def api_revoke_consommateur(
|
||||
consommateur_id: str, current_user=Depends(require_admin)
|
||||
):
|
||||
_exiger_actives()
|
||||
try:
|
||||
consommateur = revoquer_consommateur(consommateur_id)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"consommateur_revoque",
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="ok",
|
||||
)
|
||||
return consommateur
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance.
|
||||
# L'import ne supprime rien ; la purge exige une confirmation explicite
|
||||
# et ne supprime que les noms listés. Aucune réponse ne contient jamais
|
||||
# une valeur, seulement des noms et des comptes.
|
||||
# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être
|
||||
# capturées par le paramètre slug.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/coffres/migration/api-keys")
|
||||
async def api_migration_apercu(current_user=Depends(require_admin)):
|
||||
_exiger_actives()
|
||||
return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()}
|
||||
|
||||
|
||||
@router.post("/api/coffres/migration/api-keys/import")
|
||||
async def api_migration_import(current_user=Depends(require_admin)):
|
||||
_exiger_actives()
|
||||
try:
|
||||
resultat = importer_api_keys(cree_par=_username(current_user))
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
for nom in resultat["migres"]:
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"migration_api_keys",
|
||||
coffre=COFFRE_INTERNE_SLUG,
|
||||
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
|
||||
resultat="ok",
|
||||
)
|
||||
return resultat
|
||||
|
||||
|
||||
@router.post("/api/coffres/migration/api-keys/purger")
|
||||
async def api_migration_purger(
|
||||
body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
_exiger_actives()
|
||||
if body.get("confirmer") is not True:
|
||||
raise HTTPException(status_code=400, detail="confirmation_requise")
|
||||
try:
|
||||
resultat = purger_api_keys(
|
||||
noms=body.get("noms") or [], confirmer=True
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
for nom in resultat["supprimes"]:
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"migration_api_keys_purge",
|
||||
coffre=COFFRE_INTERNE_SLUG,
|
||||
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
|
||||
resultat="ok",
|
||||
)
|
||||
return resultat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/coffres/distribuer")
|
||||
async def api_distribuer(request: Request, body: dict = Body(...)):
|
||||
_exiger_actives()
|
||||
reference = (body.get("reference") or "").strip()
|
||||
# Phase 3 (Spark) : le contexte peut arriver à plat
|
||||
# (contexte_type / contexte_id) ou imbriqué (contexte: {type, id},
|
||||
# forme envoyée par le client Spark). Les deux formes sont acceptées.
|
||||
contexte_type = str(body.get("contexte_type") or "").strip()
|
||||
contexte_id_raw = body.get("contexte_id")
|
||||
contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip()
|
||||
if not contexte_type or not contexte_id:
|
||||
contexte = body.get("contexte")
|
||||
if isinstance(contexte, dict):
|
||||
contexte_type = contexte_type or str(contexte.get("type") or "").strip()
|
||||
contexte_id_raw = contexte.get("id")
|
||||
contexte_id = contexte_id or (
|
||||
"" if contexte_id_raw is None else str(contexte_id_raw).strip()
|
||||
)
|
||||
if not contexte_type or not contexte_id:
|
||||
raise HTTPException(status_code=400, detail="contexte_manquant")
|
||||
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
||||
raise HTTPException(status_code=400, detail="reference_invalide")
|
||||
slug, nom = reference[7:].split("/", 1)
|
||||
# Authentification consommateur : Bearer, hash + statut, jamais un JWT
|
||||
# de session utilisateur.
|
||||
auth = request.headers.get("Authorization", "")
|
||||
jeton = auth[7:] if auth.startswith("Bearer ") else ""
|
||||
consommateur = verifier_jeton_consommateur(jeton)
|
||||
if consommateur is None:
|
||||
_audit("consommateur", "distribution_refusee", secret_ref=reference,
|
||||
resultat="consommateur_invalide", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=401, detail="consommateur_invalide")
|
||||
if consommateur["statut"] != "actif":
|
||||
_audit("consommateur", "distribution_refusee", secret_ref=reference,
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="consommateur_revoque", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=403, detail="consommateur_revoque")
|
||||
try:
|
||||
coffre = get_coffre(slug)
|
||||
secret = get_secret(coffre["id"], nom) if coffre else None
|
||||
except ValidationError:
|
||||
raise HTTPException(status_code=400, detail="reference_invalide") from None
|
||||
if coffre is None or secret is None or coffre["statut"] != "actif":
|
||||
# Anti-énumération : un secret inexistant se comporte comme un
|
||||
# octroi absent, il ne révèle pas l'existence du coffre/secret.
|
||||
_audit("consommateur", "distribution_refusee", secret_ref=reference,
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="octroi_absent", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=403, detail="octroi_absent")
|
||||
statut, _octroi = statut_octroi_consommateur(
|
||||
consommateur["id"], coffre["id"], secret["id"]
|
||||
)
|
||||
if statut != "ok":
|
||||
code = {
|
||||
"revoque": "octroi_revoque",
|
||||
"expire": "octroi_expire",
|
||||
"absent": "octroi_absent",
|
||||
}[statut]
|
||||
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
|
||||
secret_ref=reference, consommateur=consommateur["nom"],
|
||||
resultat=code, contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=403, detail=code)
|
||||
try:
|
||||
valeur = distribuer_valeur(coffre, secret)
|
||||
except SecretExpireError:
|
||||
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
|
||||
secret_ref=reference, consommateur=consommateur["nom"],
|
||||
resultat="secret_expire", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=410, detail="secret_expire") from None
|
||||
except KEKAbsenteError:
|
||||
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
|
||||
secret_ref=reference, consommateur=consommateur["nom"],
|
||||
resultat="kek_absente", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
toucher_utilisation(secret["id"])
|
||||
_audit("consommateur", "distribution_ok", coffre=coffre["slug"],
|
||||
secret_ref=reference, consommateur=consommateur["nom"],
|
||||
resultat="ok", contexte_id=contexte_id)
|
||||
return {
|
||||
"reference": reference,
|
||||
"valeur": valeur,
|
||||
"version": secret["version_courante"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Un coffre, ses secrets, ses octrois, son journal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/coffres/{slug}")
|
||||
async def api_get_coffre(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_voir(current_user, coffre)
|
||||
return _metadata_coffre(coffre)
|
||||
|
||||
|
||||
@router.get("/api/coffres/{slug}/secrets")
|
||||
async def api_list_secrets(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_voir(current_user, coffre)
|
||||
return _secrets_visibles(current_user, coffre)
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/secrets")
|
||||
async def api_create_secret(
|
||||
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
|
||||
):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
secret = create_secret(
|
||||
coffre=coffre,
|
||||
nom=body.get("nom", ""),
|
||||
type_secret=body.get("type", "autre"),
|
||||
valeur=body.get("valeur", ""),
|
||||
service=body.get("service", ""),
|
||||
expiration=body.get("expiration"),
|
||||
note=body.get("note", ""),
|
||||
)
|
||||
except SecretExisteError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"secret_cree",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="ok",
|
||||
)
|
||||
return _metadata_secret(coffre, secret)
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/secrets/{nom}/rotation")
|
||||
async def api_rotate_secret(
|
||||
slug: str, nom: str, body: dict = Body(...), current_user=Depends(require_auth)
|
||||
):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
secret = rotate_secret(coffre, nom, body.get("valeur", ""))
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"secret_rotation",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="ok",
|
||||
)
|
||||
return _metadata_secret(coffre, secret)
|
||||
|
||||
|
||||
@router.delete("/api/coffres/{slug}/secrets/{nom}")
|
||||
async def api_delete_secret(
|
||||
slug: str, nom: str, current_user=Depends(require_auth)
|
||||
):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
secret, _octrois = delete_secret(coffre, nom)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"secret_supprime",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="ok",
|
||||
)
|
||||
return {"status": "deleted", "nom": secret["nom"]}
|
||||
|
||||
|
||||
@router.get("/api/coffres/{slug}/octrois")
|
||||
async def api_list_octrois(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_voir(current_user, coffre)
|
||||
return list_octrois(coffre["id"])
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/octrois")
|
||||
async def api_create_octroi(
|
||||
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
|
||||
):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
secret_id = None
|
||||
if body.get("secret_nom"):
|
||||
secret = _secret_or_404(coffre, body["secret_nom"])
|
||||
secret_id = secret["id"]
|
||||
try:
|
||||
octroi = create_octroi(
|
||||
coffre=coffre,
|
||||
cible_type=body.get("cible_type", ""),
|
||||
cible_id=body.get("cible_id", ""),
|
||||
secret_id=secret_id,
|
||||
cree_par=_username(current_user),
|
||||
expiration=body.get("expiration"),
|
||||
)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"octroi_cree",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=(
|
||||
f"coffre:{coffre['slug']}/{body.get('secret_nom', '*')}"
|
||||
),
|
||||
resultat="ok",
|
||||
)
|
||||
return octroi
|
||||
|
||||
|
||||
@router.delete("/api/coffres/octrois/{octroi_id}")
|
||||
async def api_revoke_octroi(octroi_id: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
# Le gestionnaire du coffre concerné (ou l'admin d'instance) seul peut
|
||||
# révoquer : on résout le coffre depuis l'octroi avant toute action.
|
||||
octroi = get_octroi(octroi_id)
|
||||
if octroi is None:
|
||||
raise HTTPException(status_code=404, detail="Octroi introuvable")
|
||||
coffre = get_coffre_by_id(octroi["coffre_id"])
|
||||
if coffre is None:
|
||||
raise HTTPException(status_code=404, detail="Coffre introuvable")
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
octroi = revoquer_octroi(octroi_id)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"octroi_revoque",
|
||||
coffre=coffre["slug"],
|
||||
resultat="ok",
|
||||
)
|
||||
return octroi
|
||||
|
||||
|
||||
@router.get("/api/coffres/{slug}/journal")
|
||||
async def api_journal(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_voir(current_user, coffre)
|
||||
# Défense en profondeur : même si une entrée contenait un jour un champ
|
||||
# sensible, il ne sort jamais par cette route.
|
||||
nettoyees = []
|
||||
for entry in get_recent_entries(limit=100):
|
||||
if entry.get("coffre") != slug:
|
||||
continue
|
||||
nettoyees.append(
|
||||
{
|
||||
k: v
|
||||
for k, v in entry.items()
|
||||
if k not in ("valeur", "ciphertext", "nonce", "jeton", "dek_chiffree")
|
||||
}
|
||||
)
|
||||
return nettoyees
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Administration KEK / rotations (admin seulement)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/coffres/admin/generer-kek")
|
||||
async def api_generer_kek(current_user=Depends(require_admin)):
|
||||
_exiger_actives()
|
||||
# La KEK générée n'est rendue qu'une fois, ici, et jamais stockée par
|
||||
# ObsiGate : l'opérateur doit la sauvegarder hors dépôt immédiatement.
|
||||
return {"kek": generate_kek_b64()}
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/rotation-dek")
|
||||
async def api_rotate_dek(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
coffre = rotate_dek(slug)
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
_audit(_username(current_user), "dek_rotation", coffre=coffre["slug"],
|
||||
resultat="ok")
|
||||
return _metadata_coffre(coffre)
|
||||
|
||||
|
||||
@router.post("/api/coffres/admin/rotation-kek")
|
||||
async def api_rotate_kek(
|
||||
body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
_exiger_actives()
|
||||
try:
|
||||
count = rotate_kek(body.get("nouvelle_kek", ""))
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(_username(current_user), "kek_rotation", resultat="ok")
|
||||
return {"status": "ok", "coffres_rechiffres": count}
|
||||
@@ -49,7 +49,7 @@ async def api_events(current_user=Depends(require_auth)):
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
# Keepalive comment
|
||||
yield ": keepalive\n\n"
|
||||
except asyncio.CancelledError:
|
||||
|
||||
+12
-12
@@ -26,7 +26,7 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -118,7 +118,7 @@ def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(at)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
|
||||
return {"kind": kind, "at": parsed.isoformat()}
|
||||
@@ -138,7 +138,7 @@ def create_task(
|
||||
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
|
||||
validated_action = _validate_action(action)
|
||||
validated_schedule = _validate_schedule(schedule)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
task = {
|
||||
@@ -200,9 +200,9 @@ def delete_task(task_id: str) -> bool:
|
||||
|
||||
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
|
||||
"""Compute the next due datetime for *task*."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
now = now or datetime.now(UTC)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
now = now.replace(tzinfo=UTC)
|
||||
schedule = task.get("schedule", {})
|
||||
kind = schedule.get("kind")
|
||||
last_run_at = task.get("last_run_at")
|
||||
@@ -211,7 +211,7 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at))
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
last = last.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
last = None
|
||||
if kind == "interval_hours":
|
||||
@@ -233,11 +233,11 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
|
||||
try:
|
||||
at = datetime.fromisoformat(str(schedule.get("at")))
|
||||
if at.tzinfo is None:
|
||||
at = at.replace(tzinfo=timezone.utc)
|
||||
at = at.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
return now
|
||||
if task.get("last_run_at"):
|
||||
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
|
||||
return datetime.max.replace(tzinfo=UTC) # déjà exécutée
|
||||
return at
|
||||
return now + timedelta(hours=24)
|
||||
|
||||
@@ -278,7 +278,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
||||
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
|
||||
try:
|
||||
result = _execute_action(task)
|
||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["last_run_at"] = datetime.now(UTC).isoformat()
|
||||
task["last_status"] = "ok"
|
||||
task["last_error"] = None
|
||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||
@@ -292,7 +292,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
||||
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
|
||||
return {"task_id": task_id, "ok": True, "result": result}
|
||||
except Exception as e:
|
||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["last_run_at"] = datetime.now(UTC).isoformat()
|
||||
task["last_status"] = "error"
|
||||
task["last_error"] = str(e)
|
||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||
@@ -315,7 +315,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
||||
|
||||
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
||||
"""Run every due task. Returns per-task outcomes (empty when idle)."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
now = now or datetime.now(UTC)
|
||||
outcomes: list[dict[str, Any]] = []
|
||||
for task in _read_tasks():
|
||||
if not task.get("enabled", True):
|
||||
@@ -323,7 +323,7 @@ def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
||||
try:
|
||||
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
|
||||
if next_run.tzinfo is None:
|
||||
next_run = next_run.replace(tzinfo=timezone.utc)
|
||||
next_run = next_run.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
next_run = compute_next_run(task, now)
|
||||
if next_run <= now:
|
||||
|
||||
@@ -14,7 +14,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -145,7 +145,7 @@ def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
try:
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
dt = datetime.fromtimestamp(ts, tz=UTC).isoformat()
|
||||
except (OSError, OverflowError, ValueError) as ts_err:
|
||||
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
|
||||
continue
|
||||
@@ -219,7 +219,7 @@ def diff_backup(
|
||||
status=500,
|
||||
details={"vault": vault_name, "path": path, "version": ts},
|
||||
) from e
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
dt = datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
return content, f"{path}@{dt}"
|
||||
|
||||
try:
|
||||
|
||||
@@ -117,7 +117,7 @@ def list_recent(
|
||||
for v_name, f in recent:
|
||||
iso_modified = f.get("modified", "")
|
||||
try:
|
||||
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
|
||||
mtime_dt = datetime.fromisoformat(iso_modified)
|
||||
mtime_val = mtime_dt.timestamp()
|
||||
except Exception:
|
||||
mtime_val = time.time()
|
||||
|
||||
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -49,7 +50,7 @@ def get_vault_root(vault_name: str) -> Path:
|
||||
|
||||
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"""Return the direct children of a vault directory (directories first)."""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
from backend.vault_settings import get_vault_setting
|
||||
@@ -98,7 +99,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"type": "file",
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||
"extension": entry.suffix.lower(),
|
||||
})
|
||||
except PermissionError:
|
||||
@@ -156,7 +157,7 @@ def list_all_files(
|
||||
ServiceError: ``not_found`` (404) if the directory does not exist,
|
||||
``permission_denied`` (403) if it cannot be read.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import IGNORED_DIRS
|
||||
|
||||
@@ -215,7 +216,7 @@ def list_all_files(
|
||||
"vault": vault_name,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||
"extension": ext.lstrip(".") if ext else "",
|
||||
"tags": _indexed_tags(vault_name, rel_path),
|
||||
}
|
||||
|
||||
+5
-5
@@ -11,7 +11,7 @@ import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.share")
|
||||
@@ -58,7 +58,7 @@ def create_share(
|
||||
|
||||
expires_at = None
|
||||
if expires_in_hours:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
expires_at = (datetime.now(UTC) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
|
||||
share = {
|
||||
"id": token,
|
||||
@@ -66,7 +66,7 @@ def create_share(
|
||||
"vault": vault,
|
||||
"path": path,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
@@ -86,7 +86,7 @@ def get_share_by_token(token: str) -> dict | None:
|
||||
return None
|
||||
if share.get("expires_at"):
|
||||
expires = datetime.fromisoformat(share["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires:
|
||||
if datetime.now(UTC) > expires:
|
||||
return None
|
||||
return share
|
||||
|
||||
@@ -98,7 +98,7 @@ def record_access(token: str):
|
||||
share = data["shares"].get(token)
|
||||
if share:
|
||||
share["access_count"] = share.get("access_count", 0) + 1
|
||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||||
share["last_accessed"] = datetime.now(UTC).isoformat()
|
||||
_write(data)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Reuses the application audit log (``data/audit.log``, JSON lines) and adds an
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from backend.audit import _write_entry
|
||||
@@ -41,7 +41,7 @@ def log_tool_call(
|
||||
) -> None:
|
||||
"""Append an ``ai_tool_call`` entry to the audit log."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "ai_tool_call",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@ class VaultWatcher:
|
||||
key = event.get('dest') or event['src']
|
||||
pending[key] = event
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
if pending:
|
||||
events_to_process = list(pending.values())
|
||||
pending.clear()
|
||||
|
||||
+4
-4
@@ -31,7 +31,7 @@ import os
|
||||
import socket
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -275,7 +275,7 @@ def create_webhook(
|
||||
"url": url,
|
||||
"events": [e for e in events if e in VALID_EVENTS],
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"last_fired_at": None,
|
||||
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
|
||||
"vault": _normalize_vault_filter(vault),
|
||||
@@ -344,7 +344,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
||||
|
||||
payload = {
|
||||
"event": event_type,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
body = json.dumps(payload, default=str)
|
||||
@@ -369,7 +369,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
||||
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
|
||||
else:
|
||||
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
|
||||
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
|
||||
update_webhook(wh["id"], {"last_fired_at": datetime.now(UTC).isoformat()})
|
||||
except Exception as e:
|
||||
logger.warning(f"Webhook '{wh['name']}' error: {e}")
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.79.0"
|
||||
version = "2.81.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.79.0"
|
||||
version = "2.81.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.79.0",
|
||||
"version": "2.81.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Coffre Secrets ObsiGate — Contrat de distribution (Phases 3 et 4)
|
||||
|
||||
Référence du contrat pour les clients serveur-à-serveur : Spark
|
||||
(issue #21), TikTak, Flowdeck, Kanbante (issue #22). Ce document ne
|
||||
duplique aucune logique de chiffrement : le chiffrement reste
|
||||
entièrement côté ObsiGate (enveloppe KEK → DEK → secret, AES-256-GCM).
|
||||
|
||||
## 1. Endpoint
|
||||
|
||||
```
|
||||
POST /api/coffres/distribuer
|
||||
Authorization: Bearer <jeton-consommateur>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Corps (forme à plat, Phase 1b) :
|
||||
|
||||
```json
|
||||
{
|
||||
"reference": "coffre:<slug>/<nom>",
|
||||
"contexte_type": "mission",
|
||||
"contexte_id": "mission-123"
|
||||
}
|
||||
```
|
||||
|
||||
Forme imbriquée acceptée depuis la Phase 2 (client Spark) :
|
||||
|
||||
```json
|
||||
{
|
||||
"reference": "coffre:<slug>/<nom>",
|
||||
"contexte": { "type": "mission", "id": "mission-123" }
|
||||
}
|
||||
```
|
||||
|
||||
La forme à plat reste supportée ; si les deux formes sont présentes,
|
||||
la forme à plat est prioritaire. Le contexte est **obligatoire** :
|
||||
sans `contexte_type`/`contexte_id` (ou sans `contexte.type`/`id`),
|
||||
la requête est refusée avant toute authentification utile.
|
||||
|
||||
Réponse en succès (200) :
|
||||
|
||||
```json
|
||||
{ "reference": "coffre:<slug>/<nom>", "valeur": "<valeur>", "version": 1 }
|
||||
```
|
||||
|
||||
La valeur n'existe qu'en mémoire chez l'appelant : elle ne doit
|
||||
jamais être écrite dans un fichier, un journal, un prompt, une étape
|
||||
d'activité ou un livrable. Le patron imposé à chaque app est
|
||||
**distribuer → utiliser → oublier** ; à la fin de l'opération
|
||||
(fin de mission, fin de synchronisation), l'environnement qui la
|
||||
contenait est détruit et rien ne persiste.
|
||||
|
||||
Prérequis côté ObsiGate : coffres activés
|
||||
(`OBSIGATE_SECRETS_ENABLED`, sinon 503 `secrets_desactives`) et KEK
|
||||
configurée (sinon 503 `kek_absente`).
|
||||
|
||||
## 2. Authentification : consommateur nommé, jeton propre
|
||||
|
||||
- Un consommateur est créé par un admin :
|
||||
`POST /api/coffres/consommateurs` (`{"nom": "spark"}`), ou en une
|
||||
seule opération avec ses octrois :
|
||||
`POST /api/coffres/consommateurs/avec-octrois`
|
||||
(`{"nom": "tiktak", "octrois": [{"coffre_slug": "...",
|
||||
"secret_nom": "..."}]}` ; `secret_nom` absent = tout le coffre).
|
||||
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la création.
|
||||
Il n'est jamais stocké en clair (seul son hash SHA-256 est persisté)
|
||||
et jamais relisté (`GET /api/coffres/consommateurs` ne renvoie que
|
||||
`id`, `nom`, `jeton_prefixe`, `statut`, `cree_par`, `cree_le`).
|
||||
- L'`id` du consommateur sert de `jti` révocable :
|
||||
`DELETE /api/coffres/consommateurs/{id}` révoque immédiatement
|
||||
toutes ses distributions, sans toucher aux autres consommateurs.
|
||||
- Ce jeton est **distinct** du jeton MCP de notes et de la session
|
||||
SSO : une session utilisateur, même admin, ne distribue jamais
|
||||
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
|
||||
distribution par session (voir §6).
|
||||
- Ajouter une app = enregistrer un consommateur + créer des octrois,
|
||||
**sans modification du coffre** et sans toucher à ses secrets.
|
||||
|
||||
## 3. Cas Spark (issue #21)
|
||||
|
||||
- Consommateur nommé `spark` (convention, non imposée par le code :
|
||||
l'autorisation porte sur le jeton, pas sur le nom).
|
||||
- Contexte : `contexte_type = "mission"`, `contexte_id` = identifiant
|
||||
de la mission. La forme imbriquée
|
||||
`contexte: {type: "mission", id: <id-mission>}` est acceptée.
|
||||
- Flux côté agent-commun (hors ObsiGate, rappelé ici pour la
|
||||
correspondance des erreurs) : le LLM ne voit que la référence
|
||||
`coffre:<slug>/<nom>` et un accès oui/non, jamais la valeur. Sans
|
||||
octroi, la mission passe en `acces_requis` en nommant l'accès
|
||||
manquant. Avec octroi et mission approuvée (enveloppe budgétaire),
|
||||
le service Spark appelle `/distribuer` et injecte la valeur en
|
||||
variable d'environnement de l'exécuteur, jamais dans le prompt.
|
||||
Fin de mission = environnement détruit.
|
||||
- Révocation dans ObsiGate (consommateur ou octroi) : la mission
|
||||
suivante repasse en `acces_requis`. Limite assumée : une mission
|
||||
déjà démarrée conserve son environnement jusqu'à sa fin.
|
||||
|
||||
## 4. Erreurs (codes `detail`, stables depuis la Phase 1b)
|
||||
|
||||
| HTTP | `detail` | Sens | Correspondance Spark |
|
||||
| --- | --- | --- | --- |
|
||||
| 400 | `contexte_manquant` | Contexte absent ou vide. | Erreur d'appel, pas un état d'accès. |
|
||||
| 400 | `reference_invalide` | Référence mal formée ou nom invalide. | Erreur d'appel. |
|
||||
| 401 | `consommateur_invalide` | Jeton inconnu (ni MCP, ni session). | Configurer le jeton consommateur. |
|
||||
| 403 | `consommateur_revoque` | Consommateur révoqué. | `acces_requis` (ré-accorder). |
|
||||
| 403 | `octroi_absent` | Aucun octroi (ou secret/coffre inexistant — anti-énumération, volontairement indistinct). | `acces_requis` (demander l'octroi). |
|
||||
| 403 | `octroi_revoque` | Octroi révoqué. | `acces_requis` (ré-accorder). |
|
||||
| 403 | `octroi_expire` | Octroi expiré (défaut 90 jours à la création). | `acces_requis` (renouveler). |
|
||||
| 410 | `secret_expire` | Secret expiré, même avec un octroi actif. | Rotation du secret côté ObsiGate. |
|
||||
| 503 | `secrets_desactives` / `kek_absente` / `crypto_indisponible` | Coffre désactivé, KEK absente, ou bibliothèque absente. | Panne ObsiGate, pas un refus d'accès. |
|
||||
|
||||
Un octroi coffre-wide (`secret_id` nul) couvre tous les secrets du
|
||||
coffre ; un octroi par secret ne couvre que ce secret. Un octroi
|
||||
révoqué ne doit jamais être masqué par l'absence d'un autre octroi
|
||||
(priorité : ok > révoqué > expiré > absent).
|
||||
|
||||
## 5. Audit
|
||||
|
||||
Chaque distribution est auditée (`distribution_ok` ou
|
||||
`distribution_refusee`, avec référence, consommateur, contexte et
|
||||
code de résultat). **Aucune entrée d'audit ne contient jamais une
|
||||
valeur, un texte chiffré, un nonce ou un jeton.** La route journal
|
||||
(`GET /api/coffres/{slug}/journal`) filtre en défense en profondeur
|
||||
ces champs même s'ils apparaissaient un jour dans une entrée.
|
||||
La copie manuelle à usage unique (admin, via consommateur temporaire
|
||||
créé puis révoqué immédiatement, valeur copiée au presse-papiers
|
||||
sans affichage) est auditée comme une distribution de contexte
|
||||
`copie_manuelle`, avec la trace distincte de ce contexte.
|
||||
|
||||
## 6. Limites et restes à faire (Phases 3–4, côté ObsiGate)
|
||||
|
||||
- **Isolation workspace Flowdeck** : le type de cible `workspace`
|
||||
existe pour les octrois, mais `/distribuer` n'autorise aujourd'hui
|
||||
que les octrois de type `consommateur`. L'isolation « un workspace
|
||||
ne peut pas utiliser l'octroi d'un autre » repose donc, pour
|
||||
l'instant, sur le serveur Flowdeck lui-même (il ne demande que les
|
||||
références de son workspace, avec son propre jeton consommateur).
|
||||
Un contrôle serveur strict (contexte workspace vérifié côté
|
||||
ObsiGate contre un octroi `workspace`) reste à spécifier et à
|
||||
implémenter si Bruno l'exige — il changerait la sémantique
|
||||
d'autorisation de `/distribuer` et doit être coordonné avec les
|
||||
apps clientes avant tout changement.
|
||||
- **Distribution par session** : non implémentée, volontairement.
|
||||
Elle contredirait la règle Phase 1b (distribution réservée aux
|
||||
consommateurs) et exigerait une ré-authentification qui n'existe
|
||||
pas encore dans ObsiGate. La copie manuelle reste le patron
|
||||
supporté (consommateur temporaire, admin seulement).
|
||||
- **Clients par app** (TikTak, Flowdeck, Kanbante, agent-commun) :
|
||||
à implémenter dans chaque dépôt, en branche + PR, jamais mergés
|
||||
sans go. Patron commun : le serveur distribue, utilise, oublie ;
|
||||
aucune valeur en cache ; si ObsiGate est indisponible, l'opération
|
||||
échoue proprement, sans repli sur une ancienne valeur.
|
||||
- **Lecteurs internes ObsiGate** (`ai.py`, `tools/secrets.py`) :
|
||||
toujours branchés sur `api_keys.json` en clair (voir le document
|
||||
de sauvegarde/restauration, §5) ; leur branchement sur le coffre
|
||||
est un travail séparé de la présente Phase 2.
|
||||
@@ -0,0 +1,390 @@
|
||||
# Coffre Secrets ObsiGate — Déploiement et mise en service (Phase 5, issue #23)
|
||||
|
||||
Document de déploiement du coffre Secrets, issu des Phases 1a/1b
|
||||
(issues #17, #18), 2 (issue #20), 3 (issue #21, côté agent-commun)
|
||||
et 4 (issue #22, côté TikTak, Flowdeck, Kanbante).
|
||||
|
||||
**Règles de gouvernance (non négociables) :** travail en branche +
|
||||
pull request, jamais sur `main`, jamais de merge, de release, de tag,
|
||||
de fermeture d'issue ni de changement de label sans le go explicite
|
||||
de Bruno. Ce document ne décrit aucune valeur réelle : les exemples
|
||||
utilisent des valeurs fictives (`TEST-VALUE-XXXX`) et aucune KEK
|
||||
n'est jamais écrite ici, ni dans un journal, ni dans une note, ni
|
||||
dans le dépôt.
|
||||
|
||||
Documents compagnons (même branche de référence, Phase 2) :
|
||||
|
||||
- `docs/COFFRE-contrat-distribution.md` — contrat
|
||||
`POST /api/coffres/distribuer` pour Spark, TikTak, Flowdeck,
|
||||
Kanbante (authentification par consommateur, contextes, codes
|
||||
d'erreur stables).
|
||||
- `docs/COFFRE-sauvegarde-restauration.md` — sauvegarde
|
||||
(`data/coffres.json` + `data/coffres_valeurs.json`, 0600) et
|
||||
restauration sur instance vierge, perte de KEK.
|
||||
|
||||
## 1. Prérequis et variables d'environnement
|
||||
|
||||
### 1.1 Dépendance
|
||||
|
||||
- `cryptography>=44.0` dans `backend/requirements.txt` (dépendance
|
||||
Phase 1a). Sans elle, les opérations de chiffrement répondent
|
||||
503 `crypto_indisponible` : c'est un mode dégradé explicite, pas
|
||||
un repli en clair. Il n'y a jamais de stockage ni de distribution
|
||||
de valeur en clair.
|
||||
- Chiffrement par enveloppe AES-256-GCM : KEK (256 bits) → DEK par
|
||||
coffre → valeur du secret. Un tag GCM invalide refuse le
|
||||
déchiffrement (testé : `tests/test_coffre_secrets.py`,
|
||||
`test_tag_invalide_refuse`).
|
||||
|
||||
### 1.2 Variables
|
||||
|
||||
| Variable | Rôle | Défaut |
|
||||
| --- | --- | --- |
|
||||
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Tant qu'elle n'est pas à `true`, **toutes** les routes coffre répondent 503 `secrets_desactives`. | désactivé |
|
||||
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). **Prioritaire** sur le fichier si les deux sont définis. | absente |
|
||||
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier contenant la KEK, **en 0600, hors dépôt et hors arborescence des vaults**. Un fichier trop permissif (droits groupe/autre) est refusé explicitement (`KEKInvalideError`). | absent |
|
||||
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut) : `coffres.json`, `coffres_valeurs.json` et l'historique `api_keys.json`. | `data` |
|
||||
|
||||
La KEK est générée une seule fois (admin, coffres activés) via
|
||||
`POST /api/coffres/admin/generer-kek` ; ObsiGate **ne la stocke
|
||||
jamais**. Au moment de sa génération : la copier immédiatement
|
||||
dans un gestionnaire de mots de passe ou sur un support hors ligne,
|
||||
hors du serveur ; vérifier la copie en la relisant depuis le
|
||||
gestionnaire, jamais depuis l'écran qui ne l'affichera plus.
|
||||
Ne jamais la coller dans le dépôt, dans une note ObsiGate, dans le
|
||||
même dossier que `data/`, ni dans un journal.
|
||||
|
||||
**Recommandation de déploiement : préférer `OBSIGATE_KEK_FILE`**
|
||||
(fichier 0600) à `OBSIGATE_KEK` en variable d'environnement, pour
|
||||
éviter que la KEK apparaisse dans un historique de processus ou un
|
||||
dump d'environnement. Si les deux sont définis, `OBSIGATE_KEK`
|
||||
l'emporte — vérifier qu'aucune ancienne valeur ne traîne dans
|
||||
l'environnement au moment de basculer sur le fichier.
|
||||
|
||||
### 1.3 Ce que la sauvegarde contient (rappel)
|
||||
|
||||
Deux fichiers seulement portent le coffre, tous deux en 0600 :
|
||||
|
||||
- `data/coffres.json` : métadonnées des coffres, DEK chiffrées,
|
||||
consommateurs (hashes SHA-256 des jetons seulement, jamais le
|
||||
jeton en clair), octrois, métadonnées des secrets ;
|
||||
- `data/coffres_valeurs.json` : versions chiffrées des secrets.
|
||||
|
||||
Copiés seuls, ils ne donnent accès à aucune valeur. La KEK ne fait
|
||||
pas partie de la sauvegarde : elle est copiée à part, hors serveur.
|
||||
Sauvegarder les deux fichiers et la KEK au même endroit annulerait
|
||||
la protection du chiffrement.
|
||||
|
||||
## 2. Décisions ouvertes de la spec — tranchées par défaut
|
||||
|
||||
Les décisions ouvertes de la spec (§15, §16), rappelées dans
|
||||
l'issue #23, sont confirmées comme tranchées selon les défauts
|
||||
recommandés, en attendant une décision contraire explicite de
|
||||
Bruno. Aucune d'elles ne nécessite de changement de code pour la
|
||||
mise en service décrite ci-dessous :
|
||||
|
||||
| Décision | Choix confirmé par défaut |
|
||||
| --- | --- |
|
||||
| Glyphe du nœud Secrets | Cadenas U+1F510 (🔐), distinct de la maison du Home et du diamant du Partage. Le nœud est un nœud virtuel sous le Home de l'utilisateur courant seulement (voir §4). |
|
||||
| Stockage de la KEK | Par fichier 0600, hors dépôt (`OBSIGATE_KEK_FILE`) ; `OBSIGATE_KEK` reste supportée comme solution de secours. |
|
||||
| Expiration des octrois | 90 jours par défaut à la création, renouvelable (création d'un nouvel octroi). Un octroi expiré refuse la distribution (`octroi_expire`, 403) ; un octroi révoqué refuse dès l'appel suivant (`octroi_revoque`, 403). |
|
||||
| Copie manuelle | Presse-papiers seul, sans affichage de la valeur. Patron supporté : consommateur temporaire créé par un admin puis révoqué immédiatement, auditée comme distribution de contexte `copie_manuelle`. **Aucun endpoint dédié de copie par session n'est ajouté** : il contredirait la règle « distribution réservée aux consommateurs » et exigerait une ré-authentification qui n'existe pas encore — décision volontaire, à trancher avec Bruno avant toute implémentation. |
|
||||
| Coffres partagés | Phase 2 ultérieure. En mise en service : coffres personnels d'abord, plus le coffre d'instance « ObsiGate interne » (slug `obsigate-interne`, admin seulement) issu de l'import. |
|
||||
|
||||
## 3. Mise en service pas à pas
|
||||
|
||||
**Ordre impératif.** Chaque étape est validée avant de passer à la
|
||||
suivante. En particulier : **les lecteurs internes sont branchés
|
||||
sur le coffre AVANT toute purge du clair** (étape 4 avant étape 7),
|
||||
et la purge n'a lieu qu'après validation complète (étape 6).
|
||||
|
||||
### Étape 0 — Préparer, coffre encore désactivé
|
||||
|
||||
1. Déployer le code coffre (Phases 1–2) sur l'instance, **sans
|
||||
activer le flag** : `OBSIGATE_SECRETS_ENABLED` reste absent ou à
|
||||
`false`. Vérifier que toutes les routes `/api/coffres/*`
|
||||
répondent 503.
|
||||
2. Sauvegarder l'état actuel : copie de `data/` (dont
|
||||
`api_keys.json`) et de la configuration, hors serveur.
|
||||
3. Générer la KEK (voir §1.2) et la copier hors serveur. Ne pas
|
||||
encore la référencer dans la configuration de l'instance si
|
||||
l'étape 1 n'est pas terminée — une KEK présente sans flag actif
|
||||
ne distribue rien, mais sa présence doit rester traçable.
|
||||
|
||||
### Étape 1 — Créer les consommateurs et les octrois
|
||||
|
||||
Avant d'activer le flag, préparer les consommateurs nommés, un par
|
||||
application cliente (convention de nommage, non imposée par le
|
||||
code — l'autorisation porte sur le jeton, pas sur le nom) :
|
||||
|
||||
| Consommateur | Usage | Contexte de distribution |
|
||||
| --- | --- | --- |
|
||||
| `spark` | Agent Spark (agent-commun, Phase 3) | `contexte_type = "mission"`, `contexte_id` = id de mission (forme imbriquée `contexte: {type, id}` acceptée) |
|
||||
| `tiktak` | Synchronisation TikTak (Phase 4) | `contexte_type = "tiktak_sync"`, `contexte_id` = id utilisateur |
|
||||
| `flowdeck` | Connecteurs Flowdeck (Phase 4) | Contexte workspace, vérifié côté Flowdeck (voir §5) |
|
||||
| `kanbante` | Webhooks Kanbante (Phase 4) | Contexte webhook, distribution à chaque appel puis oubli |
|
||||
|
||||
Création en une seule opération avec les octrois :
|
||||
|
||||
```
|
||||
POST /api/coffres/consommateurs/avec-octrois
|
||||
{"nom": "tiktak", "octrois": [{"coffre_slug": "...", "secret_nom": "..."}]}
|
||||
```
|
||||
|
||||
- `secret_nom` absent = tout le coffre (octroi coffre-wide).
|
||||
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la
|
||||
création. Le noter immédiatement dans le coffre de
|
||||
configuration de l'application cliente concernée, jamais dans
|
||||
une note ObsiGate ni dans le dépôt. Il n'est jamais relisté :
|
||||
`GET /api/coffres/consommateurs` ne renvoie que `id`, `nom`,
|
||||
`jeton_prefixe`, `statut`, `cree_par`, `cree_le`.
|
||||
- La révocation est immédiate : `DELETE
|
||||
/api/coffres/consommateurs/{id}` révoque toutes les
|
||||
distributions de ce consommateur, sans toucher aux autres.
|
||||
Une mission Spark déjà démarrée conserve son environnement
|
||||
jusqu'à sa fin ; la mission suivante repasse en `acces_requis`.
|
||||
|
||||
À ce stade, les consommateurs et octrois peuvent être créés alors
|
||||
que les secrets n'existent pas encore : les octrois pointent vers
|
||||
des coffres/secrets à créer aux étapes suivantes. Vérifier la
|
||||
liste des consommateurs et des octrois avant d'activer le flag.
|
||||
|
||||
### Étape 2 — Activer le flag
|
||||
|
||||
1. Définir `OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt) puis
|
||||
`OBSIGATE_SECRETS_ENABLED=true`.
|
||||
2. Redémarrer l'instance.
|
||||
3. Vérifier : la vue Secrets s'ouvre ; `GET /api/coffres` répond
|
||||
200 (liste vide ou coffres existants) ; sans KEK valide, les
|
||||
routes répondent 503 `kek_absente` — dans ce cas, corriger la
|
||||
KEK avant toute création de secret.
|
||||
|
||||
### Étape 3 — Importer l'existant (admin seulement)
|
||||
|
||||
Bouton « Importer l'existant » de la vue Secrets (admin
|
||||
seulement), en deux temps séparés :
|
||||
|
||||
1. **Aperçu** : `GET /api/coffres/migration/api-keys` liste les
|
||||
candidats de `data/api_keys.json` (clés IA et clés d'outils).
|
||||
2. **Import** : `POST /api/coffres/migration/api-keys/import`
|
||||
copie les entrées avec valeur vers le coffre d'instance
|
||||
« ObsiGate interne » (slug `obsigate-interne`), chiffrées.
|
||||
Un secret déjà présent n'est **jamais écrasé**.
|
||||
`api_keys.json` n'est **pas modifié** par l'import.
|
||||
|
||||
Vérifier après import : les secrets importés apparaissent dans la
|
||||
vue Secrets avec `has_value = true`, et aucune valeur n'est
|
||||
visible (métadonnées seules : nom, type, version, dates, octrois
|
||||
actifs — jamais de valeur, jamais de texte chiffré, jamais de
|
||||
nonce).
|
||||
|
||||
### Étape 4 — Brancher les lecteurs internes AVANT toute purge
|
||||
|
||||
**Point de vigilance principal de la mise en service.** Les
|
||||
lecteurs actuels d'ObsiGate (`backend/ai.py`,
|
||||
`backend/tools/secrets.py`) lisent encore `data/api_keys.json`
|
||||
directement ; ils ne sont pas encore branchés sur le coffre
|
||||
(reste à faire explicite des Phases 2–5). Tant que ce branchement
|
||||
n'est pas livré et validé, **purger le clair priverait l'IA et les
|
||||
outils de leurs clés**.
|
||||
|
||||
Ordre à respecter :
|
||||
|
||||
1. Ne pas purger.
|
||||
2. Livrer (en branche + PR, jamais mergé sans go) le branchement
|
||||
des lecteurs internes sur une distribution depuis le coffre
|
||||
d'instance, puis le valider sur l'instance : l'IA et les outils
|
||||
fonctionnent avec `api_keys.json` rendu temporairement
|
||||
inaccessible en lecture (test de coupure contrôlée), ou à
|
||||
défaut avec un secret de test distribué depuis le coffre.
|
||||
3. Seulement après cette validation, passer à l'étape 7.
|
||||
|
||||
Si Bruno accepte une interruption temporaire de l'IA/outils, le
|
||||
documenter explicitement avant la purge ; sinon, la purge attend
|
||||
le branchement.
|
||||
|
||||
### Étape 5 — Brancher les applications clientes
|
||||
|
||||
Pour chaque application (branches dédiées, jamais mergées sans
|
||||
go), appliquer le patron commun **distribuer → utiliser →
|
||||
oublier** : le serveur distribue à chaque opération, utilise la
|
||||
valeur en mémoire, puis l'oublie ; aucune valeur en cache, aucune
|
||||
persistance. Si ObsiGate est indisponible, l'opération échoue
|
||||
proprement, sans repli sur une ancienne valeur.
|
||||
|
||||
- **Spark (agent-commun, Phase 3)** : identités référencées
|
||||
`coffre:<slug>/<nom>` (slug 3–64, nom 3–128, minuscules /
|
||||
chiffres / `-` / `_`), sans colonne valeur ; le LLM ne voit que
|
||||
la référence et un accès oui/non. Sans octroi, la mission passe
|
||||
en `acces_requis` en nommant l'accès manquant ; avec octroi et
|
||||
mission approuvée (enveloppe budgétaire), distribution puis
|
||||
injection en variable d'environnement de l'exécuteur, jamais
|
||||
dans le prompt, jamais dans les étapes d'activité. Fin de
|
||||
mission = environnement détruit.
|
||||
- **TikTak (Phase 4)** : référence seule stockée ; distribution
|
||||
serveur à chaque synchro ; masquage côté Android (ni référence
|
||||
complète ni valeur dans les réponses vues par une clé `tck_`).
|
||||
Les configurations existantes sont marquées `a_resaisir = 1`
|
||||
(migration 019 : colonne en clair supprimée, référence vide) :
|
||||
il n'y a **pas de conversion automatique** — chaque
|
||||
configuration doit être re-saisie par référence.
|
||||
- **Flowdeck (Phase 4)** : `workspace_id` + référence sur les
|
||||
connecteurs ; distribution en mémoire ; isolation A vs B
|
||||
testée (un workspace ne distribue pas avec l'octroi d'un autre).
|
||||
Les connecteurs natifs Gitea/GitHub et les clés LLM ne sont pas
|
||||
basculés ; pas de sélecteur UI (API seulement).
|
||||
- **Kanbante (Phase 4)** : secret webhook migré en référence,
|
||||
distribuée à chaque webhook puis oubliée ; client générique
|
||||
réutilisable. Les jetons forge chiffrés Fernet ne sont **pas**
|
||||
migrés (distribution par appel requise, incompatible avec le
|
||||
cache `ForgeRegistry`) ; les secrets webhook historiques en
|
||||
clair restent acceptés par compatibilité, sans migration
|
||||
automatique.
|
||||
|
||||
### Étape 6 — Valider (avant purge)
|
||||
|
||||
Exécuter la checklist de validation de l'instance réelle (§4).
|
||||
Tant qu'un point échoue, ne pas purger et ne pas considérer la
|
||||
mise en service comme terminée.
|
||||
|
||||
### Étape 7 — Purger, seulement après validation
|
||||
|
||||
`POST /api/coffres/migration/api-keys/purger`, **après
|
||||
confirmation explicite** (`confirmer = true`), et uniquement pour
|
||||
les noms migrés. Sans confirmation, rien n'est supprimé — jamais
|
||||
automatiquement.
|
||||
|
||||
Après purge, vérifier :
|
||||
|
||||
- `api_keys.json` ne contient plus les noms purgés ;
|
||||
- l'IA et les outils fonctionnent toujours (lecteurs branchés,
|
||||
étape 4) ;
|
||||
- une distribution de test via un consommateur existant aboutit
|
||||
(succès seul vérifié, valeur ni affichée ni consignée).
|
||||
|
||||
### Étape 8 — Sauvegarder et tester la restauration
|
||||
|
||||
1. Sauvegarder `data/coffres.json` + `data/coffres_valeurs.json`
|
||||
(0600) par la sauvegarde habituelle du serveur, et la KEK à
|
||||
part, hors serveur (§1.3).
|
||||
2. Rejouer la restauration sur une instance vierge (procédure
|
||||
`docs/COFFRE-sauvegarde-restauration.md` §3 : arrêt de la
|
||||
cible, copie des deux fichiers en 0600, même KEK, redémarrage,
|
||||
vérification `has_value = true` + distribution de test +
|
||||
`distribution_ok` dans le journal). La suite automatisée
|
||||
(`tests/test_coffre_secrets.py`, classe
|
||||
`TestRestaurationVierge`) simule cette restauration ; elle ne
|
||||
remplace pas un essai sur instance réelle.
|
||||
3. En cas de perte de KEK : perte d'accès assumée, par
|
||||
construction — aucun moyen de récupérer les valeurs. Recréer
|
||||
les coffres avec une nouvelle KEK, régénérer les valeurs côté
|
||||
fournisseur, révoquer les anciennes côté fournisseur, recréer
|
||||
consommateurs et octrois. Ne jamais tenter de « réparer » les
|
||||
anciens fichiers avec une nouvelle KEK (le déchiffrement
|
||||
échouera sur tag GCM invalide, ce qui est attendu).
|
||||
|
||||
## 4. Checklist de validation par Bruno sur instance réelle
|
||||
|
||||
Validation exigée par l'issue #23 avant tout usage réel. Chaque
|
||||
point est validé sur capture, comme pour les autres livraisons :
|
||||
|
||||
- [ ] **Sidebar** : le nœud « Secrets » (cadenas U+1F510)
|
||||
apparaît sous le Home de l'utilisateur courant seulement, et
|
||||
pas sous les autres vaults ; cliquer dessus ouvre la vue
|
||||
Secrets sans naviguer dans les fichiers.
|
||||
- [ ] **Secret de test** : création d'un secret de test
|
||||
(valeur fictive, ex. `TEST-VALUE-XXXX`) dans un coffre
|
||||
personnel, puis lecture : métadonnées seules, aucune valeur
|
||||
affichée, aucun texte chiffré, aucun nonce.
|
||||
- [ ] **Journal d'audit** : après une distribution de test, le
|
||||
journal contient `distribution_ok` pour la référence, et un
|
||||
`grep` du journal sur la valeur de test ne renvoie **rien**
|
||||
(ni valeur, ni texte chiffré, ni nonce, ni jeton).
|
||||
- [ ] **Autorisations** : un admin ne voit pas le coffre
|
||||
personnel d'autrui ; une ACL `*` sur un vault ne donne aucun
|
||||
droit sur un coffre ; une session utilisateur, même admin, ne
|
||||
distribue pas (401 `consommateur_invalide`).
|
||||
- [ ] **Distribution** : contexte manquant refusé (400
|
||||
`contexte_manquant`) ; octroi révoqué refusé dès l'appel
|
||||
suivant (403 `octroi_revoque`) ; octroi expiré refusé (403
|
||||
`octroi_expire`) ; secret expiré refusé (410 `secret_expire`).
|
||||
- [ ] **Import / purge** : l'import ne modifie pas
|
||||
`api_keys.json` et n'écrase pas un secret existant ; la purge
|
||||
sans confirmation explicite ne supprime rien.
|
||||
- [ ] **Restauration** : sauvegarde + KEK externe restaurées sur
|
||||
une instance vierge, distributions fonctionnelles.
|
||||
- [ ] **Intégrations** : mission Spark en `acces_requis` sans
|
||||
octroi, puis injection exécuteur sans trace dans les étapes ;
|
||||
synchro TikTak sans valeur côté Android ; isolation workspace
|
||||
Flowdeck (A ne distribue pas avec l'octroi de B).
|
||||
- [ ] **Modale** : la modale de création de secret n'est plus
|
||||
coupée en haut (`.obsigate-modal` bornée au viewport, corps
|
||||
seul défilant, en-tête et pied toujours visibles).
|
||||
|
||||
## 5. Limites connues à la mise en service
|
||||
|
||||
À lire avant de déclarer la mise en service terminée :
|
||||
|
||||
- **Lecteurs internes non branchés** (`ai.py`,
|
||||
`tools/secrets.py` lisent encore `api_keys.json` en clair) :
|
||||
purger avant de les brancher priverait l'IA/outils de leurs
|
||||
clés (§3, étape 4).
|
||||
- **Endpoint dédié de copie manuelle par session non ajouté**
|
||||
(décision volontaire, §2) : la copie manuelle passe par un
|
||||
consommateur temporaire admin, presse-papiers seul.
|
||||
- **Isolation workspace Flowdeck non contrôlée côté ObsiGate** :
|
||||
`/distribuer` n'autorise aujourd'hui que les octrois de type
|
||||
`consommateur` ; l'isolation workspace repose sur le serveur
|
||||
Flowdeck lui-même (il ne demande que les références de son
|
||||
workspace, avec son propre jeton). Un contrôle serveur strict
|
||||
reste à spécifier si Bruno l'exige.
|
||||
- **Identités agent-commun** : détenteur libre hors coffre
|
||||
conservé (historique) ; révocation Identités non propagée vers
|
||||
ObsiGate (pas d'endpoint) ; exécuteurs CLI synchrones
|
||||
uniquement.
|
||||
- **TikTak** : « sélecteur » = saisie validée, pas de liste
|
||||
déroulante (aucune API de listage côté ObsiGate) ;
|
||||
configurations existantes à re-saisir, pas de conversion
|
||||
automatique.
|
||||
- **Flowdeck** : natifs Gitea/GitHub et clés LLM non basculés ;
|
||||
pas de sélecteur UI (API seulement) ; `workspace_id` pas encore
|
||||
branché depuis AgentEngine.
|
||||
- **Kanbante** : jetons forge chiffrés Fernet non migrés ;
|
||||
pas de migration automatique des secrets webhook historiques.
|
||||
- **Recherche / graphe / index** : par construction, un coffre
|
||||
n'est pas un vault de notes — il ne passe ni par l'indexeur, ni
|
||||
par la recherche, ni par le graphe, et aucun outil MCP de secret
|
||||
n'existe. Aucune métadonnée de secret ne doit apparaître dans
|
||||
ces index (vérification par revue de code, pas de suite
|
||||
automatisée dédiée — voir le bilan Phase 5).
|
||||
|
||||
## 6. Repli (rollback)
|
||||
|
||||
- Désactiver le flag (`OBSIGATE_SECRETS_ENABLED` absent ou
|
||||
`false`) : toutes les routes coffre répondent 503, les fichiers
|
||||
`coffres.json` / `coffres_valeurs.json` restent intacts et
|
||||
re-distribueront dès réactivation avec la même KEK.
|
||||
- Révoquer un consommateur (`DELETE
|
||||
/api/coffres/consommateurs/{id}`) coupe immédiatement ses
|
||||
distributions, sans toucher aux autres consommateurs ni aux
|
||||
secrets.
|
||||
- Ne jamais « réparer » des fichiers chiffrés avec une nouvelle
|
||||
KEK, ne jamais restaurer `coffres.json` sans
|
||||
`coffres_valeurs.json` (et inversement), ne jamais copier la
|
||||
KEK au même endroit que la sauvegarde.
|
||||
|
||||
## 7. Références
|
||||
|
||||
- Spec « Coffre de secrets ObsiGate » §6 (autorisations), §7/§10/§11
|
||||
(API), §13/§14 (sauvegarde/restauration), §15/§16 (tests,
|
||||
déploiement).
|
||||
- Issues ObsiGate : #17 (Phase 1a), #18 (Phase 1b), #19 (Phase 1c
|
||||
UI), #20 (Phase 2), #21 (Phase 3, Spark), #22 (Phase 4, TikTak /
|
||||
Flowdeck / Kanbante), #23 (Phase 5, ce document).
|
||||
- Suites de tests : `tests/test_coffre_secrets.py` (ObsiGate, 35
|
||||
tests), `tests/test_identites_coffre_phase3.py` (agent-commun,
|
||||
16 tests), `tests/test_coffre_reference.py` (TikTak, 17 fonctions
|
||||
/ 26 cas), `tests/test_coffre_reference_flowdeck.py` (Flowdeck,
|
||||
16 tests), `tests/test_coffre_client.py` (Kanbante, 11 fonctions
|
||||
/ 25 cas). Voir le bilan Phase 5 pour la matrice de couverture
|
||||
complète et les manques.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Coffre Secrets ObsiGate — Sauvegarde et restauration (Phase 2, issue #20)
|
||||
|
||||
Ce document décrit la seule procédure supportée pour sauvegarder et
|
||||
restaurer le coffre Secrets, ainsi que le cas de perte de la KEK.
|
||||
Il complète la spécification du coffre (§13, §14) ; il ne décrit
|
||||
aucune valeur réelle — les exemples utilisent des valeurs fictives.
|
||||
|
||||
## 1. Ce qui constitue une sauvegarde
|
||||
|
||||
Deux fichiers, et seulement ces deux fichiers, portent le coffre :
|
||||
|
||||
| Fichier | Contenu |
|
||||
| --- | --- |
|
||||
| `data/coffres.json` | Coffres (métadonnées + DEK chiffrées), consommateurs (hashes de jetons seulement), octrois, métadonnées des secrets. |
|
||||
| `data/coffres_valeurs.json` | Versions chiffrées des secrets (AES-256-GCM). |
|
||||
|
||||
Ces deux fichiers forment une **sauvegarde ordinaire chiffrée** :
|
||||
copiés seuls, ils ne donnent accès à aucune valeur — chaque DEK est
|
||||
chiffrée par la KEK, chaque valeur est chiffrée par la DEK de son
|
||||
coffre. Ils peuvent donc transiter par la sauvegarde habituelle du
|
||||
serveur, avec les mêmes permissions restrictives que sur l'instance
|
||||
(0600). Ils ne doivent jamais être déposés dans le dépôt Git, ni dans
|
||||
une note ObsiGate, ni dans un journal.
|
||||
|
||||
La **KEK ne fait pas partie de la sauvegarde** : elle est copiée à
|
||||
part, hors serveur (voir §2). Sauvegarder les deux fichiers et la KEK
|
||||
au même endroit annulerait la protection du chiffrement.
|
||||
|
||||
## 2. Sauvegarder la KEK (copie externe, hors serveur)
|
||||
|
||||
La KEK est une clé maître 256 bits (base64 de 32 octets), générée une
|
||||
seule fois via `POST /api/coffres/admin/generer-kek` (admin, coffres
|
||||
activés) et **jamais stockée par ObsiGate**. Au moment de sa
|
||||
génération :
|
||||
|
||||
1. Copiez-la immédiatement dans un gestionnaire de mots de passe ou
|
||||
sur un support hors ligne, hors du serveur ObsiGate.
|
||||
2. Ne la collez jamais dans le dépôt, jamais dans une note ObsiGate,
|
||||
jamais dans le même dossier que `data/`, jamais dans un journal.
|
||||
3. Vérifiez la copie (relisez-la depuis le gestionnaire, pas depuis
|
||||
l'écran qui ne l'affichera plus jamais).
|
||||
|
||||
Variables d'environnement (documentées aussi dans `.env.example`,
|
||||
sans aucune valeur) :
|
||||
|
||||
| Variable | Rôle | Défaut |
|
||||
| --- | --- | --- |
|
||||
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Toute route répond 503 tant qu'elle n'est pas activée. | désactivé |
|
||||
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). Prioritaire sur le fichier. | absente |
|
||||
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier 0600, hors dépôt et hors arborescence des vaults, contenant la KEK. Un fichier trop permissif est refusé. | absent |
|
||||
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut), donc des deux fichiers du coffre et de `api_keys.json`. | `data` |
|
||||
|
||||
**Aucune autre variable ne doit contenir une valeur de secret du
|
||||
coffre.** En particulier, ne créez jamais de variable du type
|
||||
`COFFRE_<nom>` avec une valeur : la seule valeur externe autorisée est
|
||||
la KEK, et elle n'est pas une valeur de secret du coffre.
|
||||
|
||||
## 3. Restauration sur une instance (y compris vierge)
|
||||
|
||||
Prérequis : une instance ObsiGate avec le coffre activé
|
||||
(`OBSIGATE_SECRETS_ENABLED`), et la **même KEK** que l'instance
|
||||
d'origine.
|
||||
|
||||
1. Arrêtez l'instance cible (ou, à défaut, assurez-vous qu'aucune
|
||||
écriture n'a lieu pendant la copie).
|
||||
2. Restaurez les deux fichiers `coffres.json` et
|
||||
`coffres_valeurs.json` dans le répertoire de données cible, en
|
||||
permissions 0600.
|
||||
3. Re-présentez la même KEK, via `OBSIGATE_KEK` ou via
|
||||
`OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt).
|
||||
4. Redémarrez l'instance.
|
||||
5. Vérifiez, sans jamais afficher ni journaliser de valeur :
|
||||
- la vue Secrets liste les coffres et les secrets avec
|
||||
`has_value = true` ;
|
||||
- une distribution de test via un consommateur existant (ou un
|
||||
consommateur de test, révoqué ensuite) aboutit — côté serveur,
|
||||
le succès seul est vérifié, la valeur n'est ni affichée ni
|
||||
consignée ;
|
||||
- le journal d'audit contient `distribution_ok` pour cette
|
||||
référence.
|
||||
|
||||
**Test de restauration obligatoire avant la mise en service réelle
|
||||
(issue #20)** : la procédure ci-dessus a été automatisée dans la
|
||||
suite de tests (`tests/test_coffre_secrets.py`, classe
|
||||
`TestRestaurationVierge`) — copie des deux fichiers vers un
|
||||
répertoire vierge, même KEK, distributions fonctionnelles — et doit
|
||||
aussi être rejouée une fois sur une instance réelle par l'opérateur
|
||||
avant de considérer la sauvegarde comme valide.
|
||||
|
||||
## 4. Perte de la KEK : perte d'accès assumée, par construction
|
||||
|
||||
Si la KEK est perdue, **il n'existe aucun moyen de récupérer les
|
||||
valeurs** : les DEK ne sont stockées que chiffrées par la KEK, et les
|
||||
valeurs ne sont stockées que chiffrées par les DEK (AES-256-GCM,
|
||||
authentifié). Il n'y a ni clé de secours, ni dérivation depuis le
|
||||
secret d'application, ni porte dérobée.
|
||||
|
||||
Procédure en cas de perte :
|
||||
|
||||
1. Considérez toutes les valeurs du coffre comme perdues.
|
||||
2. Créez de nouveaux coffres avec une nouvelle KEK.
|
||||
3. Pour chaque secret perdu, régénérez la valeur **côté fournisseur**
|
||||
(nouvelle clé API, nouveau jeton) et créez un nouveau secret avec
|
||||
cette nouvelle valeur ; révoquez les anciennes valeurs côté
|
||||
fournisseur, car elles restent valides hors d'ObsiGate tant qu'elles
|
||||
ne sont pas révoquées là-bas.
|
||||
4. Recréez les consommateurs et les octrois (les anciens octrois
|
||||
pointent vers des secrets qui n'existent plus dans les nouveaux
|
||||
coffres).
|
||||
5. Ne tentez pas de « réparer » les anciens fichiers avec une nouvelle
|
||||
KEK : le déchiffrement échouera (tag GCM invalide), ce qui est le
|
||||
comportement attendu, pas un bug.
|
||||
|
||||
La rotation KEK (`POST /api/coffres/admin/rotation-kek`) n'est pas
|
||||
une récupération : elle exige l'ancienne KEK encore valide pour
|
||||
re-chiffrer les DEK, puis le remplacement de la KEK dans
|
||||
l'environnement ou le fichier KEK.
|
||||
|
||||
## 5. Migration `api_keys.json` (Phase 2) — avertissement important
|
||||
|
||||
Le bouton « Importer l'existant » de la vue Secrets (admin seulement)
|
||||
copie les clés IA et clés d'outils de `data/api_keys.json` vers le
|
||||
coffre d'instance « ObsiGate interne » (slug `obsigate-interne`),
|
||||
en deux temps :
|
||||
|
||||
1. **Import** : les entrées avec valeur sont chiffrées dans le coffre
|
||||
(un secret déjà présent n'est jamais écrasé) ; `api_keys.json`
|
||||
n'est pas modifié.
|
||||
2. **Purge** : après confirmation explicite, et uniquement pour les
|
||||
noms migrés, les entrées sont supprimées du clair. Sans
|
||||
confirmation, rien n'est supprimé — jamais automatiquement.
|
||||
|
||||
**Avertissement** : les lecteurs actuels d'ObsiGate
|
||||
(`backend/ai.py`, `backend/tools/secrets.py`) lisent encore
|
||||
`api_keys.json` directement ; ils ne sont pas encore branchés sur le
|
||||
coffre. Purger le clair prive donc ces lecteurs de la clé tant que
|
||||
leur lecteur n'est pas branché sur une distribution depuis le coffre.
|
||||
Ne purgez que si vous avez vérifié que le consommateur concerné
|
||||
distribue depuis le coffre, ou si vous acceptez cette interruption.
|
||||
Ce branchement des lecteurs internes est un reste à faire explicite
|
||||
(voir §6).
|
||||
|
||||
## 6. Restes à faire (après cette Phase 2)
|
||||
|
||||
- Brancher les lecteurs internes (`ai.py`, `tools/secrets.py`) sur
|
||||
une distribution depuis le coffre d'instance, pour que la purge du
|
||||
clair ne coupe pas l'IA ni les outils.
|
||||
- Rejouer le test de restauration sur une instance réelle (la suite
|
||||
automatisée simule une instance vierge, elle ne remplace pas un
|
||||
essai sur l'instance de production).
|
||||
- Endpoint de distribution par session utilisateur (copie manuelle
|
||||
sans consommateur temporaire) : non ajouté ici, car il contredirait
|
||||
la règle Phase 1b (distribution réservée aux consommateurs) et
|
||||
exigerait une ré-authentification qui n'existe pas encore — décision
|
||||
à prendre avec l'opérateur avant toute implémentation.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.79.0 | **Dernière mise à jour :** 2026-10-26
|
||||
> **Version :** 2.81.0 | **Dernière mise à jour :** 2026-10-28
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js';
|
||||
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
|
||||
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
||||
import { openCoffresView, openCoffresNewSecret, openCoffresAcces, openCoffresJournal, getCoffresForSidebar, invalidateCoffresCache } from './coffres.js';
|
||||
import { performAdvancedSearch } from './search.js';
|
||||
import { t } from './i18n.js';
|
||||
import { filterChatMessages as _filterChat } from './filechat.js';
|
||||
@@ -46,6 +47,168 @@ function _sharedIcon(size = 16) {
|
||||
return i;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coffre Secrets — nœud virtuel « Secrets » sous le Home (Phase 1c, #19).
|
||||
// Patron exact du dossier virtuel « Partage » (#196) : inséré à la racine
|
||||
// du Home de l'utilisateur courant seulement, sans dossier sur disque, sans
|
||||
// passer par l'indexeur. Contrairement à « Partage », le nœud est permanent
|
||||
// (même sans coffre) : cliquer dessus ouvre la vue Secrets, qui crée le
|
||||
// coffre personnel à la première ouverture si besoin.
|
||||
// AUCUNE action de fichier n'est attachée à ce nœud ni à ses enfants :
|
||||
// ni attachTreeItemActionButton, ni ContextMenuManager (qui proposerait
|
||||
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */
|
||||
function _secretsIcon() {
|
||||
const s = document.createElement("span");
|
||||
s.className = "coffres-icon-lock";
|
||||
s.textContent = "\u{1F510}";
|
||||
return s;
|
||||
}
|
||||
|
||||
function _isOwnHome(vaultName) {
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
return !!me && vaultName === `home-${me}`;
|
||||
}
|
||||
|
||||
/** Construit le nœud Secrets + son conteneur d'enfants (coffres). */
|
||||
async function _buildSecretsNode(vaultName) {
|
||||
const pair = document.createDocumentFragment();
|
||||
const item = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Secrets", "data-type": "virtual-secrets" }, [icon("chevron-right", 14), _secretsIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("coffres.secrets"))])]);
|
||||
const sub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Secrets` });
|
||||
item.addEventListener("click", async () => {
|
||||
scrollTreeItemIntoView(item, false);
|
||||
if (sub.classList.contains("collapsed")) {
|
||||
if (sub.children.length === 0) await _fillSecretsNode(item, sub, vaultName);
|
||||
sub.classList.remove("collapsed");
|
||||
const chev = item.querySelector("[data-lucide]");
|
||||
if (chev) chev.setAttribute("data-lucide", "chevron-down");
|
||||
safeCreateIcons();
|
||||
} else {
|
||||
sub.classList.add("collapsed");
|
||||
const chev = item.querySelector("[data-lucide]");
|
||||
if (chev) chev.setAttribute("data-lucide", "chevron-right");
|
||||
safeCreateIcons();
|
||||
}
|
||||
await openCoffresView();
|
||||
closeMobileSidebar();
|
||||
});
|
||||
item.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
_openSecretsMenu(e.clientX, e.clientY);
|
||||
});
|
||||
const actionBtn = el("button", { class: "tree-item-action-btn", type: "button", title: t("coffres.secrets") }, [document.createTextNode("\u22EE")]);
|
||||
actionBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const r = actionBtn.getBoundingClientRect();
|
||||
_openSecretsMenu(Math.min(r.right, window.innerWidth - 16), r.bottom);
|
||||
});
|
||||
item.appendChild(actionBtn);
|
||||
pair.appendChild(item);
|
||||
pair.appendChild(sub);
|
||||
// Compteur et enfants, en arrière-plan : le nœud existe même si l'API échoue.
|
||||
_fillSecretsNode(item, sub, vaultName).catch(() => {});
|
||||
return pair;
|
||||
}
|
||||
|
||||
/** Remplit le compteur et les enfants (coffres) d'un nœud Secrets existant. */
|
||||
async function _fillSecretsNode(item, sub, vaultName) {
|
||||
const data = await getCoffresForSidebar();
|
||||
item.querySelectorAll(".badge-small, .coffres-alert-dot").forEach((n) => n.remove());
|
||||
if (!data.disabled && data.personnel) {
|
||||
if (!data.personnel.kek_configuree) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "coffres-alert-dot";
|
||||
dot.textContent = "\u26A0";
|
||||
dot.title = t("coffres.kek_absente");
|
||||
item.appendChild(dot);
|
||||
} else {
|
||||
item.appendChild(smallBadge(data.personnel.nb_secrets));
|
||||
}
|
||||
}
|
||||
sub.innerHTML = "";
|
||||
const coffres = (data.coffres || []).slice().sort((a, b) => {
|
||||
const aPers = a.proprietaire === (AuthManager.getUser() || {}).username && a.perimetre === "personnel" ? 0 : 1;
|
||||
const bPers = b.proprietaire === (AuthManager.getUser() || {}).username && b.perimetre === "personnel" ? 0 : 1;
|
||||
return aPers - bPers || String(a.nom).localeCompare(String(b.nom));
|
||||
});
|
||||
coffres.forEach((c) => {
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
const label = c.proprietaire === me ? c.nom : `${c.nom} (${c.proprietaire})`;
|
||||
const child = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Secrets/${c.slug}`, "data-type": "virtual-coffre", "data-coffre-slug": c.slug }, [icon("lock", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(label)]), smallBadge(c.nb_secrets)]);
|
||||
child.addEventListener("click", async () => {
|
||||
scrollTreeItemIntoView(child, false);
|
||||
await openCoffresView(c.slug);
|
||||
closeMobileSidebar();
|
||||
});
|
||||
child.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
_openSecretsMenu(e.clientX, e.clientY);
|
||||
});
|
||||
sub.appendChild(child);
|
||||
});
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
/** Rafraîchit tous les nœuds Secrets affichés (après création, rotation, révocation…). */
|
||||
async function _refreshSecretsNodes() {
|
||||
const items = Array.from(document.querySelectorAll('[data-type="virtual-secrets"]'));
|
||||
for (const item of items) {
|
||||
const vaultName = item.dataset.vault;
|
||||
const sub = item.nextElementSibling;
|
||||
if (sub && sub.classList.contains("tree-children")) {
|
||||
await _fillSecretsNode(item, sub, vaultName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.__obsigateSecretsListener) {
|
||||
window.__obsigateSecretsListener = true;
|
||||
document.addEventListener("obsigate:coffres-changed", () => {
|
||||
invalidateCoffresCache();
|
||||
_refreshSecretsNodes().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
/** Menu ⋮ du nœud Secrets : Ouvrir, Nouveau secret, Gérer les accès, Journal.
|
||||
* Menu propre, jamais ContextMenuManager (pas d'action de fichier). */
|
||||
function _openSecretsMenu(x, y) {
|
||||
document.querySelectorAll(".coffres-context-menu").forEach((n) => n.remove());
|
||||
const menu = el("div", { class: "coffres-context-menu" }, []);
|
||||
const entries = [
|
||||
[t("coffres.detail"), () => openCoffresView()],
|
||||
[t("coffres.new_secret"), () => openCoffresNewSecret()],
|
||||
[t("coffres.acces"), () => openCoffresAcces()],
|
||||
[t("coffres.journal"), () => openCoffresJournal()],
|
||||
];
|
||||
entries.forEach(([label, run]) => {
|
||||
const b = el("button", { class: "coffres-context-item", type: "button" }, [document.createTextNode(label)]);
|
||||
b.addEventListener("click", async () => {
|
||||
menu.remove();
|
||||
await run();
|
||||
closeMobileSidebar();
|
||||
});
|
||||
menu.appendChild(b);
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
const rect = menu.getBoundingClientRect();
|
||||
menu.style.left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)) + "px";
|
||||
menu.style.top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) + "px";
|
||||
const closer = (e) => {
|
||||
if (!menu.contains(e.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener("click", closer, true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
}
|
||||
};
|
||||
const onKey = (e) => { if (e.key === "Escape") { menu.remove(); document.removeEventListener("click", closer, true); document.removeEventListener("keydown", onKey); } };
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", closer, true);
|
||||
document.addEventListener("keydown", onKey);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
|
||||
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
|
||||
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
|
||||
@@ -494,6 +657,12 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
}
|
||||
});
|
||||
|
||||
// Coffre Secrets (Phase 1c) : même nœud virtuel que dans loadDirectory.
|
||||
if (me && vaultName === `home-${me}` && !dirPath) {
|
||||
const pair = await _buildSecretsNode(vaultName);
|
||||
if (pair) fragment.insertBefore(pair, fragment.firstChild);
|
||||
}
|
||||
|
||||
// Replace container content in a single batch operation to avoid flash
|
||||
container.textContent = "";
|
||||
container.appendChild(fragment);
|
||||
@@ -838,6 +1007,14 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
}
|
||||
});
|
||||
|
||||
// Coffre Secrets (Phase 1c) : nœud virtuel permanent en tête, à la racine
|
||||
// du Home de l'utilisateur courant seulement, jamais sous un autre Home
|
||||
// ni sous un vault de notes, et jamais comme un dossier physique.
|
||||
if (me && vaultName === `home-${me}` && !dirPath) {
|
||||
const pair = await _buildSecretsNode(vaultName);
|
||||
if (pair) fragment.insertBefore(pair, fragment.firstChild);
|
||||
}
|
||||
|
||||
container.appendChild(fragment);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
+103
-1
@@ -2666,5 +2666,107 @@
|
||||
"guide105.dl_md_title": "Download this guide as Markdown",
|
||||
"guide105.dl_pdf_title": "Download this guide as PDF",
|
||||
"guide105.export_title": "ObsiGate User Guide",
|
||||
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application."
|
||||
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application.",
|
||||
"coffres.acces": "Access and consumers",
|
||||
"coffres.actions": "Actions",
|
||||
"coffres.active": "Active",
|
||||
"coffres.confirm_dek_rotate": "Rotate this vault's DEK? Secrets will be re-encrypted, with no change for consumers.",
|
||||
"coffres.confirm_delete": "Delete this secret? The following grants will break:",
|
||||
"coffres.confirm_revoke_consumer": "Revoke this consumer? All of its distributions will be denied.",
|
||||
"coffres.confirm_revoke_octroi": "Revoke this grant? Access will be denied on the next call.",
|
||||
"coffres.consumer_no_distribution": "No distribution in the recent log.",
|
||||
"coffres.consumers": "Consumers",
|
||||
"coffres.copied": "Copied.",
|
||||
"coffres.copy_failed": "Copy failed.",
|
||||
"coffres.copy_generated": "Copy the generated value",
|
||||
"coffres.copy_reference": "Copy",
|
||||
"coffres.create_coffre_title": "New vault",
|
||||
"coffres.create_octroi": "Create a grant",
|
||||
"coffres.create_secret_title": "New secret",
|
||||
"coffres.created": "Created",
|
||||
"coffres.dek_rotate": "Rotate DEK",
|
||||
"coffres.delete": "Delete",
|
||||
"coffres.deleted": "Secret deleted.",
|
||||
"coffres.description": "Description",
|
||||
"coffres.detail": "Details",
|
||||
"coffres.disabled": "Secret vaults are disabled on this instance (OBSIGATE_SECRETS_ENABLED).",
|
||||
"coffres.expiration": "Expiration",
|
||||
"coffres.expired": "Expired",
|
||||
"coffres.expires": "Expires",
|
||||
"coffres.expiring_soon": "Expiring soon",
|
||||
"coffres.generate": "Generate",
|
||||
"coffres.generate_unavailable": "Generation is unavailable in this browser.",
|
||||
"coffres.grant_all_coffre": "Whole vault",
|
||||
"coffres.journal": "Access log",
|
||||
"coffres.kek_absente": "Master key missing: metadata is visible, but no secret can be created or distributed.",
|
||||
"coffres.kek_generate": "Generate a KEK",
|
||||
"coffres.kek_generate_hint": "The generated KEK is returned only once and is never stored by ObsiGate: back it up outside the repository immediately.",
|
||||
"coffres.kek_new": "New KEK",
|
||||
"coffres.kek_ok": "Master key configured.",
|
||||
"coffres.kek_once_hint": "Copy it now and back it up outside the repository: it will never be shown again.",
|
||||
"coffres.kek_rotate": "Rotate KEK",
|
||||
"coffres.kek_rotated": "KEK rotated. Remember to replace the KEK in the environment or KEK file.",
|
||||
"coffres.kek_rotation_hint": "After rotation, replace the KEK in the environment or KEK file, otherwise distributions will fail.",
|
||||
"coffres.last_use": "Last use",
|
||||
"coffres.loading": "Loading vaults…",
|
||||
"coffres.manual_copy": "One-time manual copy",
|
||||
"coffres.manual_copy_admin_only": "Admin only (no session-based distribution endpoint exists yet on the backend).",
|
||||
"coffres.manual_copy_confirm": "A temporary consumer will be created, the value will be copied to the clipboard without being shown, then the consumer will be revoked immediately. The distribution will be audited.",
|
||||
"coffres.modified": "Modified",
|
||||
"coffres.name": "Name",
|
||||
"coffres.name_hint": "Lowercase, digits, - or _ ; 3 to 128 characters (e.g. gitea-token).",
|
||||
"coffres.name_required": "A name is required.",
|
||||
"coffres.never_in_note": "Never paste the value into the note.",
|
||||
"coffres.new_coffre": "New vault",
|
||||
"coffres.new_consumer": "New consumer",
|
||||
"coffres.new_secret": "New secret",
|
||||
"coffres.no_coffre": "No visible vault.",
|
||||
"coffres.no_secrets": "No secret in this vault.",
|
||||
"coffres.note": "Note",
|
||||
"coffres.octroi_created": "Grant created.",
|
||||
"coffres.octroi_default_90": "Empty = default expiration (90 days).",
|
||||
"coffres.octroi_no_expiration": "No expiration",
|
||||
"coffres.octroi_revoked": "Grant revoked.",
|
||||
"coffres.octrois": "Grants",
|
||||
"coffres.perimetre": "Scope",
|
||||
"coffres.propose_octroi": "Propose a grant (optional)",
|
||||
"coffres.reference": "Reference",
|
||||
"coffres.revoke": "Revoke",
|
||||
"coffres.rotated": "Secret rotated: the new version is active.",
|
||||
"coffres.rotation": "Rotate",
|
||||
"coffres.rotation_title": "Rotate secret",
|
||||
"coffres.save": "Save",
|
||||
"coffres.saved": "Secret saved. The value will no longer be shown.",
|
||||
"coffres.search_placeholder": "Filter secrets…",
|
||||
"coffres.secret": "Secret",
|
||||
"coffres.secret_saved_octroi_failed": "Secret saved, but the grant failed",
|
||||
"coffres.secrets": "Secrets",
|
||||
"coffres.select_secret": "Select a secret to see its metadata.",
|
||||
"coffres.service": "Service",
|
||||
"coffres.show_before_save": "Show before saving",
|
||||
"coffres.slug": "Slug",
|
||||
"coffres.slug_hint": "Lowercase, digits, - or _ ; 3 to 64 characters.",
|
||||
"coffres.status": "Status",
|
||||
"coffres.subtitle": "Secret vaults — metadata only, a value is never shown again after it is saved.",
|
||||
"coffres.target_id": "Target id",
|
||||
"coffres.target_id_required": "A target id is required.",
|
||||
"coffres.target_type": "Target type",
|
||||
"coffres.title": "Secrets",
|
||||
"coffres.token_once": "Token (shown only once)",
|
||||
"coffres.token_once_hint": "Copy it now: it will never be shown again.",
|
||||
"coffres.type": "Type",
|
||||
"coffres.value": "Value",
|
||||
"coffres.value_required": "A value is required.",
|
||||
"coffres.version": "Version",
|
||||
"coffres.import_existing": "Import existing",
|
||||
"coffres.import_title": "Import existing keys",
|
||||
"coffres.import_has_value": "Value present",
|
||||
"coffres.import_no_value": "No value",
|
||||
"coffres.import_already_exists": "Already in the vault",
|
||||
"coffres.import_migrated": "Migrated",
|
||||
"coffres.import_warning": "The AI and tool keys from data/api_keys.json will be copied into the instance vault “ObsiGate interne”. Import deletes nothing. Clearing the plaintext is a separate step, to confirm explicitly — note: the current readers (AI, tools) still read api_keys.json, so clearing deprives them of the key until their reader is wired to the vault.",
|
||||
"coffres.import_done": "Import finished",
|
||||
"coffres.import_purge": "Delete plaintext",
|
||||
"coffres.import_purge_confirm": "Permanently delete the following migrated keys from plaintext? This cannot be undone without restoring the file:",
|
||||
"coffres.import_purged": "Plaintext deleted"
|
||||
}
|
||||
|
||||
+103
-1
@@ -2666,5 +2666,107 @@
|
||||
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
|
||||
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
|
||||
"guide105.export_title": "Guide d'utilisation ObsiGate",
|
||||
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application."
|
||||
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application.",
|
||||
"coffres.acces": "Accès et consommateurs",
|
||||
"coffres.actions": "Actions",
|
||||
"coffres.active": "Actif",
|
||||
"coffres.confirm_dek_rotate": "Pivoter la DEK de ce coffre ? Les secrets seront re-chiffrés, sans changement pour les consommateurs.",
|
||||
"coffres.confirm_delete": "Supprimer ce secret ? Les octrois suivants seront cassés :",
|
||||
"coffres.confirm_revoke_consumer": "Révoquer ce consommateur ? Toutes ses distributions seront refusées.",
|
||||
"coffres.confirm_revoke_octroi": "Révoquer cet octroi ? L'accès sera refusé dès le prochain appel.",
|
||||
"coffres.consumer_no_distribution": "Aucune distribution dans le journal récent.",
|
||||
"coffres.consumers": "Consommateurs",
|
||||
"coffres.copied": "Copié.",
|
||||
"coffres.copy_failed": "Copie impossible.",
|
||||
"coffres.copy_generated": "Copier la valeur générée",
|
||||
"coffres.copy_reference": "Copier",
|
||||
"coffres.create_coffre_title": "Nouveau coffre",
|
||||
"coffres.create_octroi": "Créer un octroi",
|
||||
"coffres.create_secret_title": "Nouveau secret",
|
||||
"coffres.created": "Créé",
|
||||
"coffres.dek_rotate": "Rotation DEK",
|
||||
"coffres.delete": "Supprimer",
|
||||
"coffres.deleted": "Secret supprimé.",
|
||||
"coffres.description": "Description",
|
||||
"coffres.detail": "Détail",
|
||||
"coffres.disabled": "Les coffres de secrets sont désactivés sur cette instance (OBSIGATE_SECRETS_ENABLED).",
|
||||
"coffres.expiration": "Expiration",
|
||||
"coffres.expired": "Expiré",
|
||||
"coffres.expires": "Expire",
|
||||
"coffres.expiring_soon": "Expire bientôt",
|
||||
"coffres.generate": "Générer",
|
||||
"coffres.generate_unavailable": "Génération indisponible dans ce navigateur.",
|
||||
"coffres.grant_all_coffre": "Tout le coffre",
|
||||
"coffres.journal": "Journal d'accès",
|
||||
"coffres.kek_absente": "Clé maître absente : les métadonnées sont visibles, mais aucun secret ne peut être créé ou distribué.",
|
||||
"coffres.kek_generate": "Générer une KEK",
|
||||
"coffres.kek_generate_hint": "La KEK générée n'est rendue qu'une fois et n'est jamais stockée par ObsiGate : sauvegardez-la hors dépôt immédiatement.",
|
||||
"coffres.kek_new": "Nouvelle KEK",
|
||||
"coffres.kek_ok": "Clé maître configurée.",
|
||||
"coffres.kek_once_hint": "Copiez-la maintenant et sauvegardez-la hors dépôt : elle ne sera plus jamais affichée.",
|
||||
"coffres.kek_rotate": "Rotation KEK",
|
||||
"coffres.kek_rotated": "KEK pivotée. Pensez à remplacer la KEK dans l'environnement ou le fichier KEK.",
|
||||
"coffres.kek_rotation_hint": "Après la rotation, remplacez la KEK dans l'environnement ou le fichier KEK, sinon les distributions échoueront.",
|
||||
"coffres.last_use": "Dernière utilisation",
|
||||
"coffres.loading": "Chargement des coffres…",
|
||||
"coffres.manual_copy": "Copie manuelle à usage unique",
|
||||
"coffres.manual_copy_admin_only": "Réservée aux administrateurs (aucun endpoint de distribution par session n'existe encore côté backend).",
|
||||
"coffres.manual_copy_confirm": "Un consommateur temporaire sera créé, la valeur sera copiée au presse-papiers sans être affichée, puis le consommateur sera révoqué immédiatement. La distribution sera auditée.",
|
||||
"coffres.modified": "Modifié",
|
||||
"coffres.name": "Nom",
|
||||
"coffres.name_hint": "Minuscules, chiffres, - ou _ ; 3 à 128 caractères (ex. gitea-token).",
|
||||
"coffres.name_required": "Un nom est requis.",
|
||||
"coffres.never_in_note": "Ne collez jamais la valeur dans la note.",
|
||||
"coffres.new_coffre": "Nouveau coffre",
|
||||
"coffres.new_consumer": "Nouveau consommateur",
|
||||
"coffres.new_secret": "Nouveau secret",
|
||||
"coffres.no_coffre": "Aucun coffre visible.",
|
||||
"coffres.no_secrets": "Aucun secret dans ce coffre.",
|
||||
"coffres.note": "Note",
|
||||
"coffres.octroi_created": "Octroi créé.",
|
||||
"coffres.octroi_default_90": "Vide = expiration par défaut (90 jours).",
|
||||
"coffres.octroi_no_expiration": "Sans expiration",
|
||||
"coffres.octroi_revoked": "Octroi révoqué.",
|
||||
"coffres.octrois": "Octrois",
|
||||
"coffres.perimetre": "Périmètre",
|
||||
"coffres.propose_octroi": "Proposer un octroi (optionnel)",
|
||||
"coffres.reference": "Référence",
|
||||
"coffres.revoke": "Révoquer",
|
||||
"coffres.rotated": "Secret pivoté : la nouvelle version est active.",
|
||||
"coffres.rotation": "Rotation",
|
||||
"coffres.rotation_title": "Rotation du secret",
|
||||
"coffres.save": "Enregistrer",
|
||||
"coffres.saved": "Secret enregistré. La valeur ne sera plus affichée.",
|
||||
"coffres.search_placeholder": "Filtrer les secrets…",
|
||||
"coffres.secret": "Secret",
|
||||
"coffres.secret_saved_octroi_failed": "Secret enregistré, mais l'octroi a échoué",
|
||||
"coffres.secrets": "Secrets",
|
||||
"coffres.select_secret": "Sélectionnez un secret pour voir ses métadonnées.",
|
||||
"coffres.service": "Service",
|
||||
"coffres.show_before_save": "Afficher avant l'enregistrement",
|
||||
"coffres.slug": "Identifiant (slug)",
|
||||
"coffres.slug_hint": "Minuscules, chiffres, - ou _ ; 3 à 64 caractères.",
|
||||
"coffres.status": "Statut",
|
||||
"coffres.subtitle": "Coffres de secrets — métadonnées seules, aucune valeur n'est jamais affichée après l'enregistrement.",
|
||||
"coffres.target_id": "Identifiant de la cible",
|
||||
"coffres.target_id_required": "L'identifiant de la cible est requis.",
|
||||
"coffres.target_type": "Type de cible",
|
||||
"coffres.title": "Secrets",
|
||||
"coffres.token_once": "Jeton (affiché une seule fois)",
|
||||
"coffres.token_once_hint": "Copiez-le maintenant : il ne sera plus jamais affiché.",
|
||||
"coffres.type": "Type",
|
||||
"coffres.value": "Valeur",
|
||||
"coffres.value_required": "Une valeur est requise.",
|
||||
"coffres.version": "Version",
|
||||
"coffres.import_existing": "Importer l'existant",
|
||||
"coffres.import_title": "Importer les clés existantes",
|
||||
"coffres.import_has_value": "Valeur présente",
|
||||
"coffres.import_no_value": "Aucune valeur",
|
||||
"coffres.import_already_exists": "Déjà dans le coffre",
|
||||
"coffres.import_migrated": "Migré",
|
||||
"coffres.import_warning": "Les clés IA et clés d'outils de data/api_keys.json seront copiées dans le coffre d'instance « ObsiGate interne ». L'import ne supprime rien. La purge du clair est une étape séparée, à confirmer explicitement — attention : les lecteurs actuels (IA, outils) lisent encore api_keys.json, la purge les prive donc de la clé tant que leur lecteur n'est pas branché sur le coffre.",
|
||||
"coffres.import_done": "Import terminé",
|
||||
"coffres.import_purge": "Supprimer le clair",
|
||||
"coffres.import_purge_confirm": "Supprimer définitivement du clair les clés migrées suivantes ? Cette action est irréversible sans restauration du fichier :",
|
||||
"coffres.import_purged": "Clair supprimé"
|
||||
}
|
||||
|
||||
+48
-2
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease;
|
||||
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
/* Correctif Phase 2 (modale de création de secret coupée en haut) :
|
||||
sans max-height ni scroll interne, un formulaire long dépassait le
|
||||
viewport. La modale est désormais bornée au viewport, en colonne
|
||||
flex, avec seul le corps qui défile ; l'en-tête et le pied restent
|
||||
toujours visibles. Sur petit écran, la largeur est bornée aussi. */
|
||||
min-width: min(400px, calc(100vw - 32px));
|
||||
max-width: min(600px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: scale(0.9);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.obsigate-modal-title {
|
||||
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
|
||||
|
||||
.obsigate-modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.obsigate-modal-footer {
|
||||
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
@@ -14331,3 +14345,35 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
||||
color: var(--text-primary); font: inherit; font-size: 13.5px;
|
||||
}
|
||||
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Coffre Secrets (Phase 1c, issue #19) — vue Secrets + nœud sidebar.
|
||||
* Réutilise les variables de thème existantes ; aucune valeur de secret
|
||||
* n'est jamais affichée par ces styles (colonne valeur = •••••• en texte).
|
||||
* --------------------------------------------------------------------------- */
|
||||
.coffres-view { padding: 16px 20px; max-width: 1100px; }
|
||||
.coffres-subtitle { color: var(--text-muted); margin: 0 0 12px; }
|
||||
.coffres-banner { padding: 10px 12px; margin: 12px 0; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.coffres-banner.warn { border-color: var(--warning, #f59e0b); }
|
||||
.coffres-chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
||||
.coffres-chip { white-space: nowrap; }
|
||||
.coffres-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
|
||||
.coffres-actions .modal-input { flex: 1 1 220px; }
|
||||
.coffres-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.coffres-table th, .coffres-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border, #21262d); vertical-align: top; }
|
||||
.coffres-table tbody tr { cursor: pointer; }
|
||||
.coffres-table tbody tr:hover { background: var(--bg-hover, #1f2430); }
|
||||
.coffres-detail { margin-top: 16px; padding: 14px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; }
|
||||
.coffres-dl { display: grid; grid-template-columns: 160px 1fr; gap: 4px 12px; margin: 12px 0; }
|
||||
.coffres-dl dt { color: var(--text-muted); }
|
||||
.coffres-dl dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.coffres-line { padding: 6px 0; border-bottom: 1px solid var(--border, #21262d); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: space-between; }
|
||||
.coffres-empty { color: var(--text-muted); }
|
||||
.coffres-alerts { margin: 12px 0; }
|
||||
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
||||
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
||||
.coffres-icon-lock { font-size: 16px; line-height: 1; }
|
||||
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
|
||||
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
||||
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.79.0",
|
||||
"version": "2.81.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
"""Tests du coffre Secrets ObsiGate (Phase 1a / 1b, issues #17 et #18).
|
||||
|
||||
Couverture exigée par les issues :
|
||||
- chiffrement/déchiffrement, tag invalide refusé, rotations (secret, DEK,
|
||||
KEK), mode dégradé sans KEK ;
|
||||
- matrice d'autorisation, y compris admin contre coffre personnel d'autrui
|
||||
et ACL « * » qui ne donne aucun droit sur un coffre ;
|
||||
- contexte manquant refusé, octroi révoqué refusé au prochain appel,
|
||||
expiration respectée, audit complet sans valeur ;
|
||||
- lectures = métadonnées seules, jamais de valeur ni de texte chiffré.
|
||||
|
||||
Toutes les valeurs de secret utilisées ici sont fictives et ne servent
|
||||
qu'aux tests. Le store est isolé par test via OBSIGATE_DATA_DIR et le
|
||||
journal d'audit via backend.audit.AUDIT_LOG_FILE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend import audit as audit_module
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.coffre_crypto import (
|
||||
KEKAbsenteError,
|
||||
KEKInvalideError,
|
||||
TagInvalideError,
|
||||
decrypt_secret_value,
|
||||
encrypt_secret_value,
|
||||
generate_dek,
|
||||
generate_kek_b64,
|
||||
load_kek,
|
||||
parse_kek,
|
||||
unwrap_dek,
|
||||
wrap_dek,
|
||||
)
|
||||
from backend.coffre_store import (
|
||||
CoffreExisteError,
|
||||
SecretExpireError,
|
||||
ValidationError,
|
||||
create_coffre,
|
||||
create_secret,
|
||||
distribuer_valeur,
|
||||
get_coffre_or_404,
|
||||
rotate_dek,
|
||||
rotate_kek,
|
||||
rotate_secret,
|
||||
)
|
||||
from backend.routers.coffres import router as coffres_router
|
||||
|
||||
VALEUR_TEST = "VALEUR-TEST-FICTIVE-7f3a9c-ne-pas-utiliser"
|
||||
VALEUR_TEST_2 = "VALEUR-TEST-FICTIVE-2-b4d8e1-ne-pas-utiliser"
|
||||
|
||||
# Utilisateur courant injecté dans les routes via dependency_overrides.
|
||||
_COURANT = {"user": None}
|
||||
|
||||
|
||||
def _fake_auth():
|
||||
if _COURANT["user"] is None:
|
||||
raise HTTPException(status_code=401, detail="Authentification requise")
|
||||
return _COURANT["user"]
|
||||
|
||||
|
||||
def _fake_admin():
|
||||
user = _fake_auth()
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Accès admin requis")
|
||||
return user
|
||||
|
||||
|
||||
def _user(username: str, role: str = "user", vaults: list | None = None) -> dict:
|
||||
return {
|
||||
"username": username,
|
||||
"role": role,
|
||||
"vaults": vaults if vaults is not None else [f"home-{username}"],
|
||||
"active": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_coffre(tmp_path, monkeypatch):
|
||||
"""Environnement isolé : store, audit, KEK et feature flag."""
|
||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "1")
|
||||
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
|
||||
monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False)
|
||||
monkeypatch.setattr(audit_module, "AUDIT_LOG_FILE", tmp_path / "audit.log")
|
||||
_COURANT["user"] = _user("bruno", role="admin")
|
||||
yield tmp_path
|
||||
_COURANT["user"] = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(env_coffre):
|
||||
app = FastAPI()
|
||||
app.include_router(coffres_router)
|
||||
app.dependency_overrides[require_auth] = _fake_auth
|
||||
app.dependency_overrides[require_admin] = _fake_admin
|
||||
with TestClient(app, raise_server_exceptions=False) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _connecter(client, username: str, role: str = "user", vaults=None):
|
||||
_COURANT["user"] = _user(username, role=role, vaults=vaults)
|
||||
|
||||
|
||||
def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VALEUR_TEST):
|
||||
resp = client.post(
|
||||
"/api/coffres",
|
||||
json={"slug": slug, "nom": "Personnel", "perimetre": "personnel"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post(
|
||||
f"/api/coffres/{slug}/secrets",
|
||||
json={
|
||||
"nom": nom,
|
||||
"type": "token",
|
||||
"service": "Gitea",
|
||||
"valeur": valeur,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"):
|
||||
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
consommateur = resp.json()
|
||||
jeton = consommateur["jeton"]
|
||||
resp = client.post(
|
||||
f"/api/coffres/{slug}/octrois",
|
||||
json={
|
||||
"cible_type": "consommateur",
|
||||
"cible_id": consommateur["id"],
|
||||
"secret_nom": nom,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return consommateur, jeton, resp.json()
|
||||
|
||||
|
||||
def _distribuer(client, jeton, reference="coffre:personnel/gitea-token",
|
||||
contexte_type="mission", contexte_id="mission-test-1"):
|
||||
return client.post(
|
||||
"/api/coffres/distribuer",
|
||||
json={
|
||||
"reference": reference,
|
||||
"contexte_type": contexte_type,
|
||||
"contexte_id": contexte_id,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {jeton}"},
|
||||
)
|
||||
|
||||
|
||||
def _fichiers_sans_valeur(tmp_path, valeur=VALEUR_TEST):
|
||||
for path in tmp_path.iterdir():
|
||||
if path.is_file() and path.suffix in (".json", ".log"):
|
||||
assert valeur not in path.read_text(encoding="utf-8"), path.name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crypto (Phase 1a)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrypto:
|
||||
def test_aller_retour_aes_gcm(self):
|
||||
kek = bytes(range(32))
|
||||
dek = generate_dek()
|
||||
wrapped = wrap_dek(dek, kek, "coffre-test")
|
||||
assert unwrap_dek(wrapped, kek, "coffre-test") == dek
|
||||
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
|
||||
assert decrypt_secret_value(dek, "secret-1", 1, chiffre["nonce"], chiffre["ciphertext"]) == VALEUR_TEST
|
||||
|
||||
def test_tag_invalide_refuse(self):
|
||||
dek = generate_dek()
|
||||
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
|
||||
alt = bytearray(base64.b64decode(chiffre["ciphertext"]))
|
||||
alt[-1] ^= 1
|
||||
chiffre_alt = {
|
||||
"nonce": chiffre["nonce"],
|
||||
"ciphertext": base64.b64encode(bytes(alt)).decode("ascii"),
|
||||
}
|
||||
with pytest.raises(TagInvalideError):
|
||||
decrypt_secret_value(dek, "secret-1", 1, chiffre_alt["nonce"], chiffre_alt["ciphertext"])
|
||||
|
||||
def test_aad_incorrect_refuse(self):
|
||||
dek = generate_dek()
|
||||
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
|
||||
with pytest.raises(TagInvalideError):
|
||||
decrypt_secret_value(dek, "secret-2", 1, chiffre["nonce"], chiffre["ciphertext"])
|
||||
|
||||
def test_parse_kek_invalide(self, monkeypatch):
|
||||
with pytest.raises(KEKInvalideError):
|
||||
parse_kek("trop-court")
|
||||
assert len(parse_kek(generate_kek_b64())) == 32
|
||||
|
||||
def test_kek_absente(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False)
|
||||
with pytest.raises(KEKAbsenteError):
|
||||
load_kek()
|
||||
|
||||
def test_kek_fichier_0600_ok_et_trop_permissif_refuse(self, tmp_path, monkeypatch):
|
||||
kek_file = tmp_path / "kek.txt"
|
||||
kek_file.write_text(generate_kek_b64(), encoding="utf-8")
|
||||
os.chmod(kek_file, 0o600)
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
monkeypatch.setenv("OBSIGATE_KEK_FILE", str(kek_file))
|
||||
assert len(load_kek()) == 32
|
||||
os.chmod(kek_file, 0o644)
|
||||
with pytest.raises(KEKInvalideError):
|
||||
load_kek()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store (Phase 1a)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStore:
|
||||
def test_roundtrip_et_aucun_clair_au_repos(self, env_coffre):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST, service="Gitea")
|
||||
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
|
||||
_fichiers_sans_valeur(env_coffre)
|
||||
|
||||
def test_rotation_secret(self, env_coffre):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
secret = rotate_secret(coffre, "gitea-token", VALEUR_TEST_2)
|
||||
assert secret["version_courante"] == 2
|
||||
assert distribuer_valeur(coffre, secret) == VALEUR_TEST_2
|
||||
|
||||
def test_rotation_dek_puis_kek(self, env_coffre, monkeypatch):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
rotate_dek("personnel")
|
||||
assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST
|
||||
nouvelle_kek = generate_kek_b64()
|
||||
assert rotate_kek(nouvelle_kek) == 1
|
||||
monkeypatch.setenv("OBSIGATE_KEK", nouvelle_kek)
|
||||
assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST
|
||||
|
||||
def test_mode_degrade_sans_kek(self, env_coffre, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
assert coffre["dek_chiffree"] is None
|
||||
with pytest.raises(KEKAbsenteError):
|
||||
create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
|
||||
def test_validation_et_doublons(self, env_coffre):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
with pytest.raises(ValidationError):
|
||||
create_coffre("MAUVAIS SLUG !!", "X", "bruno")
|
||||
with pytest.raises(CoffreExisteError):
|
||||
create_coffre("personnel", "Autre", "bruno")
|
||||
create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
with pytest.raises(ValidationError):
|
||||
create_secret(
|
||||
coffre, "autre-secret", "token", VALEUR_TEST,
|
||||
note="api-key: ceci-ressemble-a-un-secret",
|
||||
)
|
||||
|
||||
def test_secret_expire_refuse_a_la_distribution(self, env_coffre):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
secret = create_secret(
|
||||
coffre, "vieux-jeton", "token", VALEUR_TEST, expiration=hier
|
||||
)
|
||||
with pytest.raises(SecretExpireError):
|
||||
distribuer_valeur(coffre, secret)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API et autorisations (Phase 1b)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApiAutorisations:
|
||||
def test_flag_desactive_repond_503(self, client, monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
||||
assert client.get("/api/coffres").status_code == 503
|
||||
|
||||
def test_lectures_metadonnees_seules(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.get("/api/coffres/personnel/secrets")
|
||||
assert resp.status_code == 200
|
||||
corps = resp.text
|
||||
assert VALEUR_TEST not in corps
|
||||
assert "ciphertext" not in corps
|
||||
assert "nonce" not in corps
|
||||
item = resp.json()[0]
|
||||
assert item["has_value"] is True
|
||||
assert item["reference"] == "coffre:personnel/gitea-token"
|
||||
assert "valeur" not in item
|
||||
|
||||
def test_admin_ne_voit_pas_le_coffre_personnel_d_autrui(self, client):
|
||||
_creer_coffre_secret(client) # propriétaire : bruno (admin de test)
|
||||
_connecter(client, "alice", role="user")
|
||||
assert client.get("/api/coffres/personnel").status_code == 403
|
||||
assert client.get("/api/coffres/personnel/secrets").status_code == 403
|
||||
# Même un admin avec vaults=["*"] n'a aucun droit sur un coffre
|
||||
# personnel dont il n'est pas propriétaire.
|
||||
_connecter(client, "admin2", role="admin", vaults=["*"])
|
||||
assert client.get("/api/coffres/personnel").status_code == 403
|
||||
|
||||
def test_acl_etoile_ne_donne_aucun_droit_coffre(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_connecter(client, "mallory", role="user", vaults=["*"])
|
||||
assert client.get("/api/coffres/personnel/secrets").status_code == 403
|
||||
|
||||
def test_coffre_instance_reserve_admin(self, client):
|
||||
_connecter(client, "bruno", role="user")
|
||||
resp = client.post(
|
||||
"/api/coffres",
|
||||
json={"slug": "instance", "nom": "Instance", "perimetre": "instance"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
_connecter(client, "bruno", role="admin")
|
||||
resp = client.post(
|
||||
"/api/coffres",
|
||||
json={"slug": "instance", "nom": "Instance", "perimetre": "instance"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_connecter(client, "alice", role="user")
|
||||
assert client.get("/api/coffres/instance").status_code == 403
|
||||
|
||||
def test_creation_secret_sans_kek_repond_503(self, client, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
resp = client.post("/api/coffres", json={"slug": "personnel", "nom": "Personnel"})
|
||||
assert resp.status_code == 200 # métadonnées créées en mode dégradé
|
||||
resp = client.post(
|
||||
"/api/coffres/personnel/secrets",
|
||||
json={"nom": "gitea-token", "type": "token", "valeur": VALEUR_TEST},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["detail"] == "kek_absente"
|
||||
|
||||
|
||||
class TestDistribution:
|
||||
def test_distribution_ok_puis_revoquee_puis_consommateur_revoque(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
consommateur, jeton, octroi = _creer_consommateur_octroi(client)
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["valeur"] == VALEUR_TEST
|
||||
assert resp.json()["version"] == 1
|
||||
# Le jeton complet n'est jamais relisté, ni son hash (seul le
|
||||
# préfixe d'affichage, non exploitable, apparaît).
|
||||
liste = client.get("/api/coffres/consommateurs").json()
|
||||
assert jeton not in json.dumps(liste)
|
||||
assert "jeton_hash" not in json.dumps(liste)
|
||||
assert all(set(item.keys()) == {"id", "nom", "jeton_prefixe", "statut", "cree_par", "cree_le"} for item in liste)
|
||||
# Révocation de l'octroi : refusé dès le prochain appel.
|
||||
resp = client.delete(f"/api/coffres/octrois/{octroi['id']}")
|
||||
assert resp.status_code == 200
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "octroi_revoque"
|
||||
# Révocation du consommateur : tous ses octrois tombent.
|
||||
resp = client.post(
|
||||
"/api/coffres/personnel/octrois",
|
||||
json={"cible_type": "consommateur", "cible_id": consommateur["id"], "secret_nom": "gitea-token"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = client.delete(f"/api/coffres/consommateurs/{consommateur['id']}")
|
||||
assert resp.status_code == 200
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "consommateur_revoque"
|
||||
|
||||
def test_contexte_manquant_refuse(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/distribuer",
|
||||
json={"reference": "coffre:personnel/gitea-token"},
|
||||
headers={"Authorization": f"Bearer {jeton}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "contexte_manquant"
|
||||
|
||||
def test_session_utilisateur_ne_distribue_pas(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_creer_consommateur_octroi(client)
|
||||
resp = _distribuer(client, "jeton-session-utilisateur-fictif")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["detail"] == "consommateur_invalide"
|
||||
|
||||
def test_octroi_absent_et_secret_expire(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/consommateurs", json={"nom": "sans-octroi"})
|
||||
jeton = resp.json()["jeton"]
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "octroi_absent"
|
||||
# Secret expiré : 410, même avec un octroi actif.
|
||||
_connecter(client, "bruno", role="admin")
|
||||
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
resp = client.post(
|
||||
"/api/coffres/personnel/secrets",
|
||||
json={"nom": "vieux-jeton", "type": "token", "valeur": VALEUR_TEST, "expiration": hier},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_, jeton2, _ = _creer_consommateur_octroi(client, nom="vieux-jeton")
|
||||
resp = _distribuer(client, jeton2, reference="coffre:personnel/vieux-jeton")
|
||||
assert resp.status_code == 410
|
||||
assert resp.json()["detail"] == "secret_expire"
|
||||
|
||||
def test_rotation_change_la_valeur_distribuee(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/personnel/secrets/gitea-token/rotation",
|
||||
json={"valeur": VALEUR_TEST_2},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["version"] == 2
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.json()["valeur"] == VALEUR_TEST_2
|
||||
|
||||
def test_suppression_casse_les_octrois(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
resp = client.delete("/api/coffres/personnel/secrets/gitea-token")
|
||||
assert resp.status_code == 200
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "octroi_absent"
|
||||
|
||||
def test_octroi_par_defaut_90_jours(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, _, octroi = _creer_consommateur_octroi(client)
|
||||
expiration = datetime.fromisoformat(octroi["expiration"])
|
||||
delta = expiration - datetime.now(timezone.utc)
|
||||
assert 89 <= delta.days <= 91
|
||||
|
||||
def test_audit_sans_valeur_et_journal(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
assert _distribuer(client, jeton).status_code == 200
|
||||
_fichiers_sans_valeur(env_coffre)
|
||||
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||
assert "distribution_ok" in audit
|
||||
assert VALEUR_TEST not in audit
|
||||
resp = client.get("/api/coffres/personnel/journal")
|
||||
assert resp.status_code == 200
|
||||
actions = [e["action"] for e in resp.json()]
|
||||
assert "secret_cree" in actions
|
||||
assert "distribution_ok" in actions
|
||||
assert "valeur" not in json.dumps(resp.json())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — Migration api_keys.json (issue #20)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMigrationApiKeys:
|
||||
def _ecrire_api_keys(self, tmp_path):
|
||||
(tmp_path / "api_keys.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"DEEPSEEK_API_KEY": VALEUR_TEST,
|
||||
"OBSIGATE_GITEA_TOKEN": VALEUR_TEST_2,
|
||||
"OBSIGATE_GITEA_URL": "https://example.invalid",
|
||||
"MISTRAL_API_KEY": "",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_apercu_import_purge(self, client, env_coffre):
|
||||
self._ecrire_api_keys(env_coffre)
|
||||
resp = client.get("/api/coffres/migration/api-keys")
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Aperçu = métadonnées seules, jamais une valeur.
|
||||
assert VALEUR_TEST not in resp.text
|
||||
assert VALEUR_TEST_2 not in resp.text
|
||||
candidats = {c["nom"]: c for c in resp.json()["candidats"]}
|
||||
assert candidats["deepseek_api_key"]["has_value"] is True
|
||||
assert candidats["mistral_api_key"]["has_value"] is False
|
||||
assert "obsigate_gitea_url" not in candidats # URL, pas un secret
|
||||
# Import : ne supprime rien dans api_keys.json.
|
||||
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||
assert resp.status_code == 200, resp.text
|
||||
resultat = resp.json()
|
||||
assert resultat["coffre"] == "obsigate-interne"
|
||||
assert "deepseek_api_key" in resultat["migres"]
|
||||
assert "obsigate_gitea_token" in resultat["migres"]
|
||||
assert "mistral_api_key" in resultat["sans_valeur"]
|
||||
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||
assert cles["DEEPSEEK_API_KEY"] == VALEUR_TEST
|
||||
# Les secrets migrés sont visibles en métadonnées et distribuables.
|
||||
resp = client.get("/api/coffres/obsigate-interne/secrets")
|
||||
assert resp.status_code == 200
|
||||
assert VALEUR_TEST not in resp.text
|
||||
assert any(s["nom"] == "deepseek_api_key" and s["has_value"] for s in resp.json())
|
||||
_, jeton, _ = _creer_consommateur_octroi(
|
||||
client, slug="obsigate-interne", nom="deepseek_api_key"
|
||||
)
|
||||
resp = _distribuer(
|
||||
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["valeur"] == VALEUR_TEST
|
||||
# Purge sans confirmation : refusée, fichier intact.
|
||||
resp = client.post(
|
||||
"/api/coffres/migration/api-keys/purger",
|
||||
json={"confirmer": False, "noms": ["deepseek_api_key"]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||
assert "DEEPSEEK_API_KEY" in cles
|
||||
# Purge confirmée : seules les clés listées disparaissent.
|
||||
resp = client.post(
|
||||
"/api/coffres/migration/api-keys/purger",
|
||||
json={"confirmer": True, "noms": ["deepseek_api_key", "obsigate_gitea_token"]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert set(resp.json()["supprimes"]) == {
|
||||
"deepseek_api_key",
|
||||
"obsigate_gitea_token",
|
||||
}
|
||||
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||
assert "DEEPSEEK_API_KEY" not in cles
|
||||
assert "OBSIGATE_GITEA_TOKEN" not in cles
|
||||
assert cles["OBSIGATE_GITEA_URL"] == "https://example.invalid"
|
||||
# Audit sans valeur.
|
||||
_fichiers_sans_valeur(env_coffre)
|
||||
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||
assert "migration_api_keys" in audit
|
||||
assert "migration_api_keys_purge" in audit
|
||||
|
||||
def test_import_ne_ecrase_pas_existant(self, client, env_coffre):
|
||||
self._ecrire_api_keys(env_coffre)
|
||||
assert client.post("/api/coffres/migration/api-keys/import", json={}).status_code == 200
|
||||
(env_coffre / "api_keys.json").write_text(
|
||||
json.dumps({"DEEPSEEK_API_KEY": VALEUR_TEST_2}), encoding="utf-8"
|
||||
)
|
||||
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||
assert resp.status_code == 200
|
||||
assert "deepseek_api_key" in resp.json()["deja_existants"]
|
||||
_, jeton, _ = _creer_consommateur_octroi(
|
||||
client, slug="obsigate-interne", nom="deepseek_api_key"
|
||||
)
|
||||
resp = _distribuer(
|
||||
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
|
||||
)
|
||||
assert resp.json()["valeur"] == VALEUR_TEST # valeur d'origine conservée
|
||||
|
||||
def test_import_sans_kek_repond_503(self, client, env_coffre, monkeypatch):
|
||||
# Une clé à migrer est nécessaire : sans valeur à chiffrer,
|
||||
# l'import réussit trivialement même sans KEK (mode dégradé).
|
||||
self._ecrire_api_keys(env_coffre)
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["detail"] == "kek_absente"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — Restauration sur instance vierge (issue #20)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRestaurationVierge:
|
||||
def test_restauration_meme_kek_distribue(self, env_coffre, tmp_path):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
|
||||
# Simulation d'une instance vierge : seuls les deux fichiers sont
|
||||
# copiés, la KEK (variable d'environnement) est inchangée.
|
||||
restaure = tmp_path / "restaure"
|
||||
restaure.mkdir()
|
||||
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||
shutil.copy(env_coffre / nom, restaure / nom)
|
||||
coffre_r = get_coffre_or_404("personnel", restaure)
|
||||
assert distribuer_valeur(coffre_r, secret, restaure) == VALEUR_TEST
|
||||
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||
assert VALEUR_TEST not in (restaure / nom).read_text(encoding="utf-8")
|
||||
|
||||
def test_restauration_mauvaise_kek_refuse(self, env_coffre, tmp_path, monkeypatch):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
restaure = tmp_path / "restaure"
|
||||
restaure.mkdir()
|
||||
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||
shutil.copy(env_coffre / nom, restaure / nom)
|
||||
# Une autre KEK ne déchiffre rien : tag GCM invalide, pas de valeur.
|
||||
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
|
||||
coffre_r = get_coffre_or_404("personnel", restaure)
|
||||
with pytest.raises(TagInvalideError):
|
||||
distribuer_valeur(coffre_r, secret, restaure)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — Contexte Spark imbriqué (issue #21)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDistributionSpark:
|
||||
def test_contexte_nested_mission(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/distribuer",
|
||||
json={
|
||||
"reference": "coffre:personnel/gitea-token",
|
||||
"contexte": {"type": "mission", "id": "mission-test-2"},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {jeton}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["valeur"] == VALEUR_TEST
|
||||
|
||||
def test_contexte_nested_sans_octroi_acces_requis(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
|
||||
jeton = resp.json()["jeton"]
|
||||
resp = client.post(
|
||||
"/api/coffres/distribuer",
|
||||
json={
|
||||
"reference": "coffre:personnel/gitea-token",
|
||||
"contexte": {"type": "mission", "id": "mission-test-3"},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {jeton}"},
|
||||
)
|
||||
# Côté Spark, ce code se traduit par l'état acces_requis.
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "octroi_absent"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4 — Consommateur + octrois en un appel (issue #22)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConsommateurAvecOctrois:
|
||||
def test_avec_octrois_distribue(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/consommateurs/avec-octrois",
|
||||
json={
|
||||
"nom": "tiktak",
|
||||
"octrois": [{"coffre_slug": "personnel", "secret_nom": "gitea-token"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
corps = resp.json()
|
||||
assert corps["nom"] == "tiktak"
|
||||
assert corps["jeton"].startswith("csk_")
|
||||
assert len(corps["octrois"]) == 1
|
||||
assert corps["erreurs"] == []
|
||||
resp = _distribuer(client, corps["jeton"])
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["valeur"] == VALEUR_TEST
|
||||
liste = client.get("/api/coffres/consommateurs").json()
|
||||
assert corps["jeton"] not in json.dumps(liste)
|
||||
assert "jeton_hash" not in json.dumps(liste)
|
||||
|
||||
def test_avec_octrois_erreur_signalee_sans_annuler(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/consommateurs/avec-octrois",
|
||||
json={
|
||||
"nom": "flowdeck",
|
||||
"octrois": [
|
||||
{"coffre_slug": "personnel", "secret_nom": "gitea-token"},
|
||||
{"coffre_slug": "inexistant", "secret_nom": "gitea-token"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert len(resp.json()["octrois"]) == 1
|
||||
assert len(resp.json()["erreurs"]) == 1
|
||||
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
|
||||
Reference in New Issue
Block a user