secrets: afficher/masquer + copier la valeur, revelation gestionnaire auditee (og/coffres.js)
This commit is contained in:
+63
-62
@@ -4,9 +4,15 @@
|
||||
*
|
||||
* Règles de sécurité appliquées ici (jamais assouplies) :
|
||||
* - 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 de valeur de secret après enregistrement : colonne valeur =
|
||||
* « •••••• » seulement, aucun bouton « afficher ».
|
||||
* jamais un texte chiffré, jamais un nonce). La valeur d'un secret
|
||||
* enregistré reste donc masquée (« •••••• ») par défaut ; seul le
|
||||
* 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
|
||||
* 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).
|
||||
@@ -14,10 +20,11 @@
|
||||
* 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,
|
||||
* ils sont perdus. Ce ne sont pas des valeurs de secret du coffre.
|
||||
* - La copie manuelle à usage unique (admin seulement, voir
|
||||
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
||||
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
||||
* d'endpoint de distribution par session utilisateur côté backend.
|
||||
* - L'ancienne copie manuelle à usage unique par consommateur
|
||||
* temporaire a été supprimée : elle échouait systématiquement
|
||||
* (aucun octroi pour le consommateur temporaire, donc 403
|
||||
* 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
|
||||
* data/api_keys.json vers le coffre d'instance « ObsiGate interne »,
|
||||
* en deux temps (import, puis purge confirmée). Aucune valeur ne
|
||||
@@ -52,6 +59,8 @@ let _coffresCache = null;
|
||||
let _coffresCacheAt = 0;
|
||||
const CACHE_MS = 30000;
|
||||
|
||||
let _valeurAffichee = null; // valeur révélée du détail courant, sinon null
|
||||
|
||||
const viewState = {
|
||||
coffres: [],
|
||||
currentSlug: null,
|
||||
@@ -354,6 +363,7 @@ function renderAlerts(host) {
|
||||
|
||||
function renderDetail(host) {
|
||||
const s = viewState.detail;
|
||||
_valeurAffichee = null;
|
||||
host.innerHTML = '';
|
||||
if (!s) {
|
||||
host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty'));
|
||||
@@ -377,7 +387,9 @@ function renderDetail(host) {
|
||||
const dl = el('dl', { class: 'coffres-dl' }, []);
|
||||
rows.forEach(([k, v]) => {
|
||||
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);
|
||||
const actions = el('div', { class: 'coffres-actions' }, []);
|
||||
@@ -386,11 +398,36 @@ function renderDetail(host) {
|
||||
actions.appendChild(modalBtn(t('coffres.acces'), '', () => {
|
||||
document.getElementById('coffres-acces')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}));
|
||||
actions.appendChild(modalBtn(t('coffres.manual_copy'), '', () => copieManuelle(s)));
|
||||
if (!isAdmin()) {
|
||||
actions.lastChild.disabled = true;
|
||||
actions.lastChild.title = t('coffres.manual_copy_admin_only');
|
||||
}
|
||||
const btnAfficher = modalBtn(t('coffres.show_value'), '', async () => {
|
||||
if (_valeurAffichee !== null) {
|
||||
_valeurAffichee = null;
|
||||
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)));
|
||||
host.appendChild(actions);
|
||||
|
||||
@@ -819,55 +856,19 @@ function openCreateCoffreModal() {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Copie manuelle à usage unique, admin seulement.
|
||||
* Le backend n'expose pas (encore) de distribution par session : on passe
|
||||
* par un consommateur temporaire créé, utilisé une fois, puis révoqué
|
||||
* immédiatement. La valeur est copiée au presse-papiers sans jamais être
|
||||
* affichée, puis la variable est effacée, même en cas d'échec. */
|
||||
async function copieManuelle(s) {
|
||||
if (!isAdmin()) return;
|
||||
const ok = await showConfirm({
|
||||
title: t('coffres.manual_copy'),
|
||||
message: t('coffres.manual_copy_confirm'),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
/** Révélation manuelle (gestionnaire du coffre seulement, auditée
|
||||
* côté backend). Ne conserve rien : l'appelant affiche ou copie
|
||||
* puis efface. Un lecteur par octroi utilisateur reçoit 403. */
|
||||
async function revelerValeur(s) {
|
||||
const ref = (s.reference || '').replace(/^coffre:/, '');
|
||||
const [slug, nom] = ref.split('/');
|
||||
const res = await fetch(
|
||||
`/api/coffres/${encodeURIComponent(slug)}/secrets/${encodeURIComponent(nom)}/reveler`,
|
||||
{ method: 'POST', credentials: 'include' },
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`);
|
||||
return data.valeur;
|
||||
}
|
||||
|
||||
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
|
||||
|
||||
Reference in New Issue
Block a user