[Coffre Secrets] Phase 1b : routeur /api/coffres, autorisations, octrois, distribution
This commit is contained in:
@@ -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}
|
||||||
Reference in New Issue
Block a user