[Coffre Secrets] Phase 1 complète : chiffrement, API et UI (1a + 1b + 1c) #25

Merged
bruno merged 12 commits from feat/coffre-secrets-phase1 into main 2026-10-27 22:29:10 -04:00
12 changed files with 3629 additions and 2 deletions
+35
View File
@@ -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(timezone.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.
+204
View File
@@ -0,0 +1,204 @@
"""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
logger = logging.getLogger("obsigate.coffre_crypto")
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 # type: ignore[assignment]
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() -> None:
if AESGCM is None:
raise CryptoIndisponibleError(
"cryptography n'est pas installé (dépendance Phase 1a)"
)
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."""
_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."""
_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("utf-8")
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("utf-8")
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)
+860
View File
@@ -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 datetime, timedelta, timezone
from pathlib import Path
from backend.coffre_crypto import (
KEKAbsenteError,
generate_dek,
load_kek,
parse_kek,
reencrypt_secret_value,
unwrap_dek,
wrap_dek,
encrypt_secret_value,
decrypt_secret_value,
)
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(timezone.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=timezone.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(timezone.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 = {
"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(timezone.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
View File
@@ -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
+2
View File
@@ -73,3 +73,5 @@ 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
+606
View File
@@ -0,0 +1,606 @@
"""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.
"""
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_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.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
# ---------------------------------------------------------------------------
# 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()
contexte_type = (body.get("contexte_type") or "").strip()
contexte_id = (body.get("contexte_id") or "").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}
File diff suppressed because it is too large Load Diff
+177
View File
@@ -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();
}
+92 -1
View File
@@ -2666,5 +2666,96 @@
"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"
}
+92 -1
View File
@@ -2666,5 +2666,96 @@
"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"
}
+32
View File
@@ -14331,3 +14331,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); }
+457
View File
@@ -0,0 +1,457 @@
"""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
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())