Merge pull request 'secrets: afficher/masquer + copier la valeur dans le panneau détail' (#57) from secrets-afficher-copier into main
This commit was merged in pull request #57.
This commit is contained in:
@@ -12,6 +12,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
|
|||||||
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
|
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
|
||||||
un consommateur authentifié par jeton (hash + jti, jamais la session
|
un consommateur authentifié par jeton (hash + jti, jamais la session
|
||||||
utilisateur), avec un octroi actif et un contexte obligatoire.
|
utilisateur), avec un octroi actif et un contexte obligatoire.
|
||||||
|
- Seule exception, voulue : ``POST /api/coffres/{slug}/secrets/{nom}/reveler``
|
||||||
|
rend la valeur au gestionnaire du coffre authentifié par sa session
|
||||||
|
(propriétaire, ou admin sur coffre d'instance — jamais un simple
|
||||||
|
lecteur par octroi utilisateur), pour l'affichage et la copie manuelle
|
||||||
|
dans le panneau détail. Chaque révélation est auditée
|
||||||
|
(``revelation_manuelle``) et met à jour la dernière utilisation ; la
|
||||||
|
valeur n'est jamais journalisée et le frontend l'efface dès qu'elle
|
||||||
|
est masquée ou que le détail change.
|
||||||
- Autorisations (spec §6) : le propriétaire seul sur son coffre
|
- Autorisations (spec §6) : le propriétaire seul sur son coffre
|
||||||
personnel ; l'admin gère les coffres d'instance, les consommateurs et
|
personnel ; l'admin gère les coffres d'instance, les consommateurs et
|
||||||
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
|
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
|
||||||
@@ -622,6 +630,50 @@ async def api_rotate_secret(
|
|||||||
return _metadata_secret(coffre, secret)
|
return _metadata_secret(coffre, secret)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/coffres/{slug}/secrets/{nom}/reveler")
|
||||||
|
async def api_reveler_secret(
|
||||||
|
slug: str, nom: str, current_user=Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""Révélation manuelle pour le panneau détail (afficher / copier).
|
||||||
|
|
||||||
|
Session utilisateur + gestion du coffre exigées : un lecteur par
|
||||||
|
octroi utilisateur voit les métadonnées mais ne révèle jamais la
|
||||||
|
valeur. Chaque appel est audité et touche la dernière utilisation.
|
||||||
|
"""
|
||||||
|
_exiger_actives()
|
||||||
|
coffre = _coffre_or_404(slug)
|
||||||
|
_exiger_gerer(current_user, coffre)
|
||||||
|
secret = _secret_or_404(coffre, nom)
|
||||||
|
try:
|
||||||
|
valeur = distribuer_valeur(coffre, secret)
|
||||||
|
except SecretExpireError:
|
||||||
|
_audit(_username(current_user), "revelation_refusee",
|
||||||
|
coffre=coffre["slug"],
|
||||||
|
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||||
|
resultat="secret_expire")
|
||||||
|
raise HTTPException(status_code=410, detail="secret_expire") from None
|
||||||
|
except KEKAbsenteError:
|
||||||
|
_audit(_username(current_user), "revelation_refusee",
|
||||||
|
coffre=coffre["slug"],
|
||||||
|
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||||
|
resultat="kek_absente")
|
||||||
|
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||||
|
except CryptoIndisponibleError:
|
||||||
|
_audit(_username(current_user), "revelation_refusee",
|
||||||
|
coffre=coffre["slug"],
|
||||||
|
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||||
|
resultat="crypto_indisponible")
|
||||||
|
raise HTTPException(status_code=503,
|
||||||
|
detail="crypto_indisponible") from None
|
||||||
|
toucher_utilisation(secret["id"])
|
||||||
|
_audit(_username(current_user), "revelation_manuelle",
|
||||||
|
coffre=coffre["slug"],
|
||||||
|
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||||
|
resultat="ok")
|
||||||
|
return {"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||||
|
"valeur": valeur, "version": secret["version_courante"]}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/coffres/{slug}/secrets/{nom}")
|
@router.delete("/api/coffres/{slug}/secrets/{nom}")
|
||||||
async def api_delete_secret(
|
async def api_delete_secret(
|
||||||
slug: str, nom: str, current_user=Depends(require_auth)
|
slug: str, nom: str, current_user=Depends(require_auth)
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ configurée (sinon 503 `kek_absente`).
|
|||||||
SSO : une session utilisateur, même admin, ne distribue jamais
|
SSO : une session utilisateur, même admin, ne distribue jamais
|
||||||
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
|
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
|
||||||
distribution par session (voir §6).
|
distribution par session (voir §6).
|
||||||
|
- Révélation manuelle (panneau détail, hors distribution) : `POST
|
||||||
|
/api/coffres/{slug}/secrets/{nom}/reveler` rend la valeur au
|
||||||
|
gestionnaire du coffre authentifié par sa session (propriétaire,
|
||||||
|
ou admin sur coffre d'instance), pour l'afficher ou la copier.
|
||||||
|
Ce n'est pas une distribution : aucun octroi n'est créé, aucun
|
||||||
|
consommateur ne reçoit la valeur, chaque révélation est auditée
|
||||||
|
(`revelation_manuelle`) et met à jour la dernière utilisation.
|
||||||
|
Un lecteur par octroi utilisateur (métadonnées visibles) reçoit
|
||||||
|
403. L'ancienne copie manuelle par consommateur temporaire a
|
||||||
|
été supprimée (elle échouait : 403 octroi_absent).
|
||||||
- Ajouter une app = enregistrer un consommateur + créer des octrois,
|
- Ajouter une app = enregistrer un consommateur + créer des octrois,
|
||||||
**sans modification du coffre** et sans toucher à ses secrets.
|
**sans modification du coffre** et sans toucher à ses secrets.
|
||||||
|
|
||||||
|
|||||||
+63
-62
@@ -4,9 +4,15 @@
|
|||||||
*
|
*
|
||||||
* Règles de sécurité appliquées ici (jamais assouplies) :
|
* Règles de sécurité appliquées ici (jamais assouplies) :
|
||||||
* - Le backend ne renvoie que des métadonnées (has_value, jamais la valeur,
|
* - Le backend ne renvoie que des métadonnées (has_value, jamais la valeur,
|
||||||
* jamais un texte chiffré, jamais un nonce). Ce module n'affiche donc
|
* jamais un texte chiffré, jamais un nonce). La valeur d'un secret
|
||||||
* jamais de valeur de secret après enregistrement : colonne valeur =
|
* enregistré reste donc masquée (« •••••• ») par défaut ; seul le
|
||||||
* « •••••• » seulement, aucun bouton « afficher ».
|
* gestionnaire du coffre (propriétaire, ou admin sur coffre
|
||||||
|
* d'instance) peut la révéler, via POST .../secrets/{nom}/reveler
|
||||||
|
* (session + gestion, chaque révélation auditée) : bouton
|
||||||
|
* Afficher/Masquer dans le détail, ou « Copier la valeur » sans
|
||||||
|
* l'afficher. La valeur est effacée dès qu'elle est masquée ou que
|
||||||
|
* le détail change ; un simple lecteur par octroi utilisateur ne
|
||||||
|
* révèle jamais.
|
||||||
* - Aucune valeur ne transite par un titre d'onglet, une URL, un hash, un
|
* - Aucune valeur ne transite par un titre d'onglet, une URL, un hash, un
|
||||||
* journal console ou un log. Les variables contenant une valeur sont
|
* journal console ou un log. Les variables contenant une valeur sont
|
||||||
* effacées dès que l'opération est terminée (champ vidé, variable = null).
|
* effacées dès que l'opération est terminée (champ vidé, variable = null).
|
||||||
@@ -14,10 +20,11 @@
|
|||||||
* consommateur (csk_…) et une KEK générée sont rendus UNE fois, dans une
|
* consommateur (csk_…) et une KEK générée sont rendus UNE fois, dans une
|
||||||
* modale, avec un bouton Copier et un avertissement ; la modale fermée,
|
* modale, avec un bouton Copier et un avertissement ; la modale fermée,
|
||||||
* ils sont perdus. Ce ne sont pas des valeurs de secret du coffre.
|
* ils sont perdus. Ce ne sont pas des valeurs de secret du coffre.
|
||||||
* - La copie manuelle à usage unique (admin seulement, voir
|
* - L'ancienne copie manuelle à usage unique par consommateur
|
||||||
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
* temporaire a été supprimée : elle échouait systématiquement
|
||||||
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
* (aucun octroi pour le consommateur temporaire, donc 403
|
||||||
* d'endpoint de distribution par session utilisateur côté backend.
|
* octroi_absent à la distribution). Elle est remplacée par
|
||||||
|
* « Copier la valeur », qui passe par l'endpoint de révélation.
|
||||||
* - Phase 2 (issue #20) : « Importer l'existant » migre les clés de
|
* - Phase 2 (issue #20) : « Importer l'existant » migre les clés de
|
||||||
* data/api_keys.json vers le coffre d'instance « ObsiGate interne »,
|
* data/api_keys.json vers le coffre d'instance « ObsiGate interne »,
|
||||||
* en deux temps (import, puis purge confirmée). Aucune valeur ne
|
* en deux temps (import, puis purge confirmée). Aucune valeur ne
|
||||||
@@ -52,6 +59,8 @@ let _coffresCache = null;
|
|||||||
let _coffresCacheAt = 0;
|
let _coffresCacheAt = 0;
|
||||||
const CACHE_MS = 30000;
|
const CACHE_MS = 30000;
|
||||||
|
|
||||||
|
let _valeurAffichee = null; // valeur révélée du détail courant, sinon null
|
||||||
|
|
||||||
const viewState = {
|
const viewState = {
|
||||||
coffres: [],
|
coffres: [],
|
||||||
currentSlug: null,
|
currentSlug: null,
|
||||||
@@ -354,6 +363,7 @@ function renderAlerts(host) {
|
|||||||
|
|
||||||
function renderDetail(host) {
|
function renderDetail(host) {
|
||||||
const s = viewState.detail;
|
const s = viewState.detail;
|
||||||
|
_valeurAffichee = null;
|
||||||
host.innerHTML = '';
|
host.innerHTML = '';
|
||||||
if (!s) {
|
if (!s) {
|
||||||
host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty'));
|
host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty'));
|
||||||
@@ -377,7 +387,9 @@ function renderDetail(host) {
|
|||||||
const dl = el('dl', { class: 'coffres-dl' }, []);
|
const dl = el('dl', { class: 'coffres-dl' }, []);
|
||||||
rows.forEach(([k, v]) => {
|
rows.forEach(([k, v]) => {
|
||||||
dl.appendChild(el('dt', {}, [document.createTextNode(k)]));
|
dl.appendChild(el('dt', {}, [document.createTextNode(k)]));
|
||||||
dl.appendChild(el('dd', {}, [document.createTextNode(v)]));
|
const dd = el('dd', {}, [document.createTextNode(v)]);
|
||||||
|
if (k === t('coffres.value')) dd.id = 'coffres-valeur';
|
||||||
|
dl.appendChild(dd);
|
||||||
});
|
});
|
||||||
host.appendChild(dl);
|
host.appendChild(dl);
|
||||||
const actions = el('div', { class: 'coffres-actions' }, []);
|
const actions = el('div', { class: 'coffres-actions' }, []);
|
||||||
@@ -386,11 +398,36 @@ function renderDetail(host) {
|
|||||||
actions.appendChild(modalBtn(t('coffres.acces'), '', () => {
|
actions.appendChild(modalBtn(t('coffres.acces'), '', () => {
|
||||||
document.getElementById('coffres-acces')?.scrollIntoView({ behavior: 'smooth' });
|
document.getElementById('coffres-acces')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}));
|
}));
|
||||||
actions.appendChild(modalBtn(t('coffres.manual_copy'), '', () => copieManuelle(s)));
|
const btnAfficher = modalBtn(t('coffres.show_value'), '', async () => {
|
||||||
if (!isAdmin()) {
|
if (_valeurAffichee !== null) {
|
||||||
actions.lastChild.disabled = true;
|
_valeurAffichee = null;
|
||||||
actions.lastChild.title = t('coffres.manual_copy_admin_only');
|
document.getElementById('coffres-valeur').textContent = '••••••';
|
||||||
}
|
btnAfficher.textContent = t('coffres.show_value');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let valeur;
|
||||||
|
try {
|
||||||
|
valeur = await revelerValeur(s);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('common.error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_valeurAffichee = valeur;
|
||||||
|
document.getElementById('coffres-valeur').textContent = valeur;
|
||||||
|
btnAfficher.textContent = t('coffres.hide_value');
|
||||||
|
});
|
||||||
|
actions.appendChild(btnAfficher);
|
||||||
|
actions.appendChild(modalBtn(t('coffres.copy_value'), '', async () => {
|
||||||
|
let valeur;
|
||||||
|
try {
|
||||||
|
valeur = await revelerValeur(s);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('common.error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await copyText(valeur);
|
||||||
|
valeur = null;
|
||||||
|
}));
|
||||||
actions.appendChild(modalBtn(t('coffres.delete'), 'danger', () => supprimerSecret(s)));
|
actions.appendChild(modalBtn(t('coffres.delete'), 'danger', () => supprimerSecret(s)));
|
||||||
host.appendChild(actions);
|
host.appendChild(actions);
|
||||||
|
|
||||||
@@ -819,55 +856,19 @@ function openCreateCoffreModal() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copie manuelle à usage unique, admin seulement.
|
/** Révélation manuelle (gestionnaire du coffre seulement, auditée
|
||||||
* Le backend n'expose pas (encore) de distribution par session : on passe
|
* côté backend). Ne conserve rien : l'appelant affiche ou copie
|
||||||
* par un consommateur temporaire créé, utilisé une fois, puis révoqué
|
* puis efface. Un lecteur par octroi utilisateur reçoit 403. */
|
||||||
* immédiatement. La valeur est copiée au presse-papiers sans jamais être
|
async function revelerValeur(s) {
|
||||||
* affichée, puis la variable est effacée, même en cas d'échec. */
|
const ref = (s.reference || '').replace(/^coffre:/, '');
|
||||||
async function copieManuelle(s) {
|
const [slug, nom] = ref.split('/');
|
||||||
if (!isAdmin()) return;
|
const res = await fetch(
|
||||||
const ok = await showConfirm({
|
`/api/coffres/${encodeURIComponent(slug)}/secrets/${encodeURIComponent(nom)}/reveler`,
|
||||||
title: t('coffres.manual_copy'),
|
{ method: 'POST', credentials: 'include' },
|
||||||
message: t('coffres.manual_copy_confirm'),
|
);
|
||||||
danger: true,
|
const data = await res.json().catch(() => ({}));
|
||||||
});
|
if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`);
|
||||||
if (!ok) return;
|
return data.valeur;
|
||||||
let consommateurId = null;
|
|
||||||
let valeur = null;
|
|
||||||
try {
|
|
||||||
const created = await api('/api/coffres/consommateurs', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ nom: `copie-manuelle-${me()}-${Date.now()}` }),
|
|
||||||
});
|
|
||||||
consommateurId = created.id;
|
|
||||||
const jeton = created.jeton;
|
|
||||||
const res = await fetch('/api/coffres/distribuer', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${jeton}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
reference: s.reference,
|
|
||||||
contexte_type: 'copie_manuelle',
|
|
||||||
contexte_id: `copie-manuelle-${Date.now()}`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`);
|
|
||||||
valeur = data.valeur;
|
|
||||||
await copyText(valeur);
|
|
||||||
} catch (e) {
|
|
||||||
showToast(e.message || t('common.error'), 'error');
|
|
||||||
} finally {
|
|
||||||
valeur = null;
|
|
||||||
if (consommateurId) {
|
|
||||||
try {
|
|
||||||
await api(`/api/coffres/consommateurs/${encodeURIComponent(consommateurId)}`, { method: 'DELETE' });
|
|
||||||
} catch { /* révocation best-effort, l'octroi reste révocable depuis Accès */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
|
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
|
||||||
|
|||||||
@@ -2680,6 +2680,9 @@
|
|||||||
"coffres.copy_failed": "Copy failed.",
|
"coffres.copy_failed": "Copy failed.",
|
||||||
"coffres.copy_generated": "Copy the generated value",
|
"coffres.copy_generated": "Copy the generated value",
|
||||||
"coffres.copy_reference": "Copy",
|
"coffres.copy_reference": "Copy",
|
||||||
|
"coffres.show_value": "Show",
|
||||||
|
"coffres.hide_value": "Hide",
|
||||||
|
"coffres.copy_value": "Copy value",
|
||||||
"coffres.create_coffre_title": "New vault",
|
"coffres.create_coffre_title": "New vault",
|
||||||
"coffres.create_octroi": "Create a grant",
|
"coffres.create_octroi": "Create a grant",
|
||||||
"coffres.create_secret_title": "New secret",
|
"coffres.create_secret_title": "New secret",
|
||||||
|
|||||||
@@ -2680,6 +2680,9 @@
|
|||||||
"coffres.copy_failed": "Copie impossible.",
|
"coffres.copy_failed": "Copie impossible.",
|
||||||
"coffres.copy_generated": "Copier la valeur générée",
|
"coffres.copy_generated": "Copier la valeur générée",
|
||||||
"coffres.copy_reference": "Copier",
|
"coffres.copy_reference": "Copier",
|
||||||
|
"coffres.show_value": "Afficher",
|
||||||
|
"coffres.hide_value": "Masquer",
|
||||||
|
"coffres.copy_value": "Copier la valeur",
|
||||||
"coffres.create_coffre_title": "Nouveau coffre",
|
"coffres.create_coffre_title": "Nouveau coffre",
|
||||||
"coffres.create_octroi": "Créer un octroi",
|
"coffres.create_octroi": "Créer un octroi",
|
||||||
"coffres.create_secret_title": "Nouveau secret",
|
"coffres.create_secret_title": "Nouveau secret",
|
||||||
|
|||||||
@@ -778,3 +778,56 @@ class TestDistribuables:
|
|||||||
# Flag désactivé : 503 comme les autres routes.
|
# Flag désactivé : 503 comme les autres routes.
|
||||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
||||||
assert self._lister(client, jeton).status_code == 503
|
assert self._lister(client, jeton).status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Révélation manuelle (panneau détail : Afficher / Copier la valeur)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRevelation:
|
||||||
|
def test_proprietaire_revele_et_audit_sans_valeur(self, client, env_coffre):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
corps = resp.json()
|
||||||
|
assert corps["valeur"] == VALEUR_TEST
|
||||||
|
assert corps["reference"] == "coffre:personnel/gitea-token"
|
||||||
|
assert corps["version"] == 1
|
||||||
|
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||||
|
assert "revelation_manuelle" in audit
|
||||||
|
assert VALEUR_TEST not in audit
|
||||||
|
|
||||||
|
def test_lecteur_octroi_utilisateur_ne_revele_pas(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post("/api/coffres/personnel/octrois", json={
|
||||||
|
"cible_type": "utilisateur", "cible_id": "alice",
|
||||||
|
"secret_nom": "gitea-token"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
_connecter(client, "alice", role="user")
|
||||||
|
# Voit les métadonnées…
|
||||||
|
assert client.get("/api/coffres/personnel/secrets").status_code == 200
|
||||||
|
# …mais ne révèle jamais la valeur.
|
||||||
|
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_secret_inexistant_404(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post("/api/coffres/personnel/secrets/absent/reveler")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_secret_expire_410(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
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, resp.text
|
||||||
|
resp = client.post("/api/coffres/personnel/secrets/vieux-jeton/reveler")
|
||||||
|
assert resp.status_code == 410
|
||||||
|
assert resp.json()["detail"] == "secret_expire"
|
||||||
|
|
||||||
|
def test_flag_desactive_503(self, client, monkeypatch):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
||||||
|
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|||||||
Reference in New Issue
Block a user