Compare commits
59
Commits
b4b3032456
...
v2.80.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ac8bf6c0 | ||
|
|
01572736c0 | ||
|
|
b6e5f01748 | ||
|
|
90da0ac990 | ||
|
|
bc26d1506b | ||
|
|
769e3ca559 | ||
|
|
b5d789f02a | ||
|
|
c8dce25e0d | ||
|
|
b322e2d24a | ||
|
|
414ec69c0a | ||
|
|
f5ca656c04 | ||
|
|
e158232aba | ||
|
|
632871ac24 | ||
|
|
7af7b2c8c2 | ||
|
|
97132770e4 | ||
|
|
683ad3ef94 | ||
|
|
604383f51c | ||
|
|
bdeb2f00b3 | ||
|
|
8688875fae | ||
|
|
a42271c7de | ||
|
|
cf4685eaf1 | ||
|
|
5023fb6774 | ||
|
|
04eb70cbe4 | ||
|
|
4dc010f54d | ||
|
|
f7e36bc44e | ||
|
|
a9ac1c06a7 | ||
|
|
9bf6e7492f | ||
|
|
14070cef43 | ||
|
|
6b1abbb50e | ||
|
|
46c641ebc5 | ||
|
|
2d036f5126 | ||
|
|
2c69e357b8 | ||
|
|
ba81f22d40 | ||
|
|
06c12d0383 | ||
|
|
69f3968d4a | ||
|
|
d5ae66466a | ||
|
|
2dc8be3127 | ||
|
|
040e80a36d | ||
|
|
bd6b79fb41 | ||
|
|
581a31161c | ||
|
|
5f8f93ba6c | ||
|
|
40b3e9c5dd | ||
|
|
2ffc219f47 | ||
|
|
02394d3278 | ||
|
|
ff1ccca3cd | ||
|
|
fddbaec82e | ||
|
|
8e073438a3 | ||
|
|
6972e9ec87 | ||
|
|
f29a22886a | ||
|
|
287afa1537 | ||
|
|
b0ac6dfdb9 | ||
|
|
7f06606300 | ||
|
|
985af3a294 | ||
|
|
440cfdcc31 | ||
|
|
0ca92c3723 | ||
|
|
f434f30286 | ||
|
|
f4b8935189 | ||
|
|
014df322e7 | ||
|
|
39f9cc16e4 |
@@ -104,3 +104,31 @@ DEEPSEEK_MODEL=deepseek-chat
|
|||||||
# OBSIGATE_GITEA_URL=https://git.example.net
|
# OBSIGATE_GITEA_URL=https://git.example.net
|
||||||
# OBSIGATE_GITEA_TOKEN=
|
# OBSIGATE_GITEA_TOKEN=
|
||||||
# OBSIGATE_GITHUB_TOKEN=
|
# OBSIGATE_GITHUB_TOKEN=
|
||||||
|
|
||||||
|
# ── Fusion / dev-prod — URL publiques de l'écosystème (#223) ──
|
||||||
|
# Aucun nom public n'est figé dans le code : ces variables portent les
|
||||||
|
# URL des autres applications. Valeurs par défaut (si absentes) = noms
|
||||||
|
# OFFICIELS de la production. Depuis BUG-120, les URL sœurs se dérivent
|
||||||
|
# de l'hôte de l'agent (agent<SUFFIXE>.dracodev.net, lu via
|
||||||
|
# AGENT_EVENEMENTS_URL / DRACO_AGENT_URL) : en dev, rien d'autre à
|
||||||
|
# déclarer pour obtenir les noms en -dev. Une variable explicite
|
||||||
|
# reste prioritaire sur la dérivation, par exemple :
|
||||||
|
# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net
|
||||||
|
# DRACO_AGENT_URL=https://agent.dracodev.net
|
||||||
|
# DRACO_OBSIGATE_URL=https://og.dracodev.net
|
||||||
|
# DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
|
||||||
|
# DRACO_TIKTAK_URL=https://tiktak.dracodev.net
|
||||||
|
# DRACO_KANBANTE_URL=https://kanbante.dracodev.net
|
||||||
|
# DRACO_GITEA_URL=https://git.dracodev.net
|
||||||
|
|
||||||
|
# ── Fusion — émission d'événements vers le service d'agents (#220) ──
|
||||||
|
# URL complète de réception ; à défaut, dérivée de DRACO_AGENT_URL.
|
||||||
|
# AGENT_EVENEMENTS_URL=https://agent.dracodev.net/api/v1/evenements
|
||||||
|
# AGENT_EVENEMENTS_CLE=<clé propre à ObsiGate, jamais commitée>
|
||||||
|
# AGENT_EVENEMENTS_DOMAINES={"NomDuVault": "famille"}
|
||||||
|
|
||||||
|
# ── Fusion — émettrice SSO draco_session (#216/#217) ──
|
||||||
|
# DRACO_SESSION_SECRET=<secret partagé, jamais commité>
|
||||||
|
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
||||||
|
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
||||||
|
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
|
||||||
|
|||||||
@@ -180,7 +180,13 @@ jobs:
|
|||||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||||
# pypdf>=6.16.1 (BUG-093).
|
# pypdf>=6.16.1 (BUG-093), relevé à pypdf>=6.21.2 par #224 (11 CVE
|
||||||
|
# sur la 6.19.0 de la toolcache, corrigées au plus tard en 6.21.2).
|
||||||
|
# #224 ajoute les planchers brotli>=1.2.1 (PYSEC-2026-4322,
|
||||||
|
# GHSA-9v8h-qqgm-4m35), filelock>=4.2.1 (CVE-2026-112940,
|
||||||
|
# CVE-2026-112913, CVE-2026-112907) et opentelemetry-api>=1.45.1
|
||||||
|
# (CVE-2026-114603) : paquets transitifs ou préinstallés dans la
|
||||||
|
# toolcache, audités mais jamais alignés sans plancher explicite.
|
||||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||||
# corrigés par le plancher urllib3>=2.8.0.
|
# corrigés par le plancher urllib3>=2.8.0.
|
||||||
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
|
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
|
||||||
|
|||||||
+192
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.74.0**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.80.1**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,197 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.80.1] — 2026-10-27
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Coffre Secrets — Phase 1** (contenu de la release 2.80.0, dont l'entrée changelog manquait) : chiffrement par enveloppe AES-256-GCM, API `/api/coffres`, nœud virtuel Secrets sous le Home de l'utilisateur.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **Étiquette de version 2.80.0** — le tag v2.80.0 pointait sur un contenu dont le fichier `VERSION` valait encore 2.79.0 (un merge ne déclenche pas le bump automatique) ; `VERSION` et tous les fichiers dérivés passent à 2.80.1, pour que la version affichée corresponde au tag de la prochaine release.
|
||||||
|
- **CI lint (ruff)** — 60 erreurs corrigées dans `backend/` : alias `datetime.UTC` (Python 3.11+), `TimeoutError` natif, tri d'imports et import inutilisé dans `coffre_store.py`, `.encode()` sans argument redondant, `fromisoformat` gérant « Z » ; aucune règle ruff masquée, aucun changement de comportement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.79.0] — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Panneau Spark : version du service d'agents visible en mode
|
||||||
|
sidebar (#225)** — le pied du panneau Spark
|
||||||
|
(`frontend/draco-panneau.js`, asset partagé des 4 applications)
|
||||||
|
affiche la ligne discrète « Service d'agents vX », comme la
|
||||||
|
console pleine page. La version est lue en direct dans la santé
|
||||||
|
du service (`GET /healthz` de l'origine injectée par l'hôte), à
|
||||||
|
l'ouverture du panneau, avec cache de session ; jamais une
|
||||||
|
valeur codée en dur — service injoignable, rien d'affiché.
|
||||||
|
Demande de Bruno (2026-10-26), carte fusion #64 ; même
|
||||||
|
livraison : TikTak 0.61.3, Flowdeck 7.83.1, Kanbante 0.37.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.78.2] — 2026-10-26
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **Menu inter-apps : les URL sœurs suivaient l'environnement de
|
||||||
|
l'agent (BUG-120, suite de #223)** — sur la dev
|
||||||
|
(`og-dev.dracodev.net`), le `window.DRACO_URLS` servi donnait
|
||||||
|
l'agent en `-dev` mais les applications sœurs (ObsiGate, Flowdeck,
|
||||||
|
TikTak, Kanbante) en noms **prod** : seule l'URL de l'agent avait
|
||||||
|
une dérivation de repli, et le `.env` dev ne déclare pas les quatre
|
||||||
|
`DRACO_<APP>_URL`. Résolution d'une URL sœur dans
|
||||||
|
`backend/draco_config.py`, inchangée dans son contrat : (1) la
|
||||||
|
variable explicite prime ; (2) à défaut, **dérivation
|
||||||
|
d'environnement** depuis l'hôte de l'agent tel qu'ObsiGate le
|
||||||
|
connaît déjà (`agent_url()`, qui lit `AGENT_EVENEMENTS_URL`) — si
|
||||||
|
cet hôte est de la forme `agent<SUFFIXE>.dracodev.net`, l'URL sœur
|
||||||
|
devient `https://<slug><SUFFIXE>.dracodev.net` (`og`, `flowdeck`,
|
||||||
|
`tiktak`, `kanbante`) ; hôte hors motif (autre domaine, LAN, IP,
|
||||||
|
port) → **aucune dérivation**, jamais de lien deviné ; (3) défaut
|
||||||
|
officiel. L'URL Gitea ne change pas. Aucune variable supplémentaire
|
||||||
|
à déclarer en dev. Tests `tests/test_draco_config.py` étendus
|
||||||
|
(dérivation `-dev`, priorité de l'explicite, hôtes hors motif,
|
||||||
|
non-régression sans env). Redéploiement Docker requis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.78.1] — 2026-10-25
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **Sécurité des dépendances — `pip-audit` rouge sur 2.78.0 (#224)** —
|
||||||
|
le job `security` du run CI 2303 signalait 17 vulnérabilités dans
|
||||||
|
4 paquets hérités de l'environnement du runner (versions
|
||||||
|
préinstallées dans la toolcache, jamais alignées car « already
|
||||||
|
satisfied » — même piège que BUG-093). Planchers explicites dans
|
||||||
|
`backend/requirements.txt` : `pypdf>=6.21.2` (11 CVE sur la 6.19.0,
|
||||||
|
dont CVE-2026-113786 et CVE-2026-115572/115585/115580),
|
||||||
|
`brotli>=1.2.1` (PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35),
|
||||||
|
`filelock>=4.2.1` (CVE-2026-112940, CVE-2026-112913,
|
||||||
|
CVE-2026-112907) et `opentelemetry-api>=1.45.1`
|
||||||
|
(CVE-2026-114603). Garde-fou `TestDependencySecurityFloors`
|
||||||
|
étendu ; aucun code applicatif touché, aucun nouvel ignore
|
||||||
|
`pip-audit`. Redéploiement Docker requis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.78.0] — 2026-10-25
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- **Dev/prod sans noms publics en dur (#223, Fusion)** — toutes les URL
|
||||||
|
publiques de l'écosystème dracodev viennent désormais de la
|
||||||
|
configuration (`backend/draco_config.py` : `DRACO_AGENT_URL`,
|
||||||
|
`DRACO_OBSIGATE_URL`, `DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
|
||||||
|
`DRACO_KANBANTE_URL`, `DRACO_GITEA_URL` ; défauts = noms officiels,
|
||||||
|
l'environnement dev déclare ses noms en `-dev` dans son seul `.env`).
|
||||||
|
La CSP (`connect-src`/`frame-src`) dérive l'origine de l'agent de la
|
||||||
|
config (à défaut, de l'origine d'`AGENT_EVENEMENTS_URL`) ; le backend
|
||||||
|
injecte `window.DRACO_URLS` dans `index.html` pour le panneau Spark,
|
||||||
|
la barre d'apps et les liens inter-apps ; les références croisées
|
||||||
|
reconnaissent les hôtes officiels et configurés et leur URL
|
||||||
|
d'affichage est reconstruite depuis la config à la lecture (aucune
|
||||||
|
donnée stockée migrée). WebAuthn confirmé dérivé de la requête /
|
||||||
|
`OBSIGATE_WEBAUTHN_*` (aucun changement). Redéploiement Docker
|
||||||
|
requis ; en dev, déclarer les noms en `-dev` dans le `.env` du poste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.77.1] — 2026-10-24
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **Panneau Spark : « mode console » bloqué par la CSP (BUG-119, Fusion)** —
|
||||||
|
la surimpression du panneau Spark V3 (#222) charge la console du service
|
||||||
|
d'agents (`https://agent.dracodev.net`) dans une iframe, mais la CSP servie
|
||||||
|
limitait `frame-src` à `'self' blob:` : le navigateur refusait le
|
||||||
|
chargement (« This content is blocked »). `frame-src` autorise désormais
|
||||||
|
aussi `https://agent.dracodev.net` — déjà présente dans `connect-src`
|
||||||
|
pour le panneau texte ; aucune autre directive n'est modifiée. Test de
|
||||||
|
non-régression dans `tests/test_security_headers.py`. Redéploiement
|
||||||
|
Docker requis pour l'activer côté ObsiGate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.77.0] — 2026-10-23
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- **Panneau Spark V3 : la console en surimpression pleine page
|
||||||
|
(#222, Fusion)** — le bouton « Console ⤢ » du panneau Spark
|
||||||
|
(asset vendangé, octet-identique dans les 4 apps) n'ouvre plus
|
||||||
|
la console web du service d'agents en nouvel onglet (#221) :
|
||||||
|
elle s'ouvre **dans la même page**, en surimpression née à la
|
||||||
|
largeur du panneau et étendue à toute la fenêtre par
|
||||||
|
transition, avec le lien profond conservé (`?disc=<id>` et
|
||||||
|
`?app=obsigate`). Sa barre offre les deux sorties : « Mode
|
||||||
|
sidebar » (retour à l'agent en barre latérale) et « Fermer
|
||||||
|
l'agent ». Les styles sont injectés par le panneau lui-même ;
|
||||||
|
côté service (0.20.0), la console autorise l'encadrement par
|
||||||
|
les 4 applications. Même livraison dans TikTak 0.60.0,
|
||||||
|
Flowdeck 7.81.0 et Kanbante 0.29.0. Redéploiement Docker
|
||||||
|
requis pour l'activer côté ObsiGate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.76.1] — 2026-10-22
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Panneau Spark : bouton « Console ↗ » (#221, Fusion)** — la
|
||||||
|
barre des discussions du panneau Spark (asset vendangé,
|
||||||
|
octet-identique dans les 4 apps) gagne un bouton « Console ↗ »
|
||||||
|
qui ouvre la console web du service d'agents
|
||||||
|
(https://agent.dracodev.net/) dans un nouvel onglet, sur la
|
||||||
|
discussion actuellement affichée (`?disc=<id>`, URL construite
|
||||||
|
au clic) et avec l'app d'origine (`?app=obsigate`, déduite du
|
||||||
|
sous-domaine `og`). Même livraison dans TikTak 0.59.1,
|
||||||
|
Flowdeck 7.80.1 et Kanbante 0.28.1. Redéploiement Docker
|
||||||
|
requis pour l'activer côté ObsiGate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.76.0] — 2026-10-22
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Émission d'événements vers le journal du service d'agents
|
||||||
|
(#220, Fusion — Lot 2 vague 5)** — ObsiGate émet ses mutations de
|
||||||
|
notes **explicites** au journal d'activité du service d'agents
|
||||||
|
(contrat ACC-0.2) : `obsigate.note.created / edited / moved /
|
||||||
|
deleted`, enveloppes signées HMAC-SHA256 (clé
|
||||||
|
`AGENT_EVENEMENTS_CLE`), envoyées en arrière-plan sans jamais
|
||||||
|
bloquer le métier, inactives tant que la clé n'est pas posée.
|
||||||
|
Points d'accroche : les endpoints d'écriture de l'API web (acteur
|
||||||
|
`user`) et la couche outils / serveur MCP (acteur `agent:mcp`).
|
||||||
|
**Garde-fou : jamais le watcher** — les modifications faites hors
|
||||||
|
API (Obsidian, éditeur externe) n'émettent rien. Domaine par vault
|
||||||
|
via `AGENT_EVENEMENTS_DOMAINES`, repli `pro` (Q-A ouverte).
|
||||||
|
Redéploiement Docker requis, puis pose de la clé des deux côtés
|
||||||
|
pour activer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.75.0] — 2026-10-22
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Panneau Spark v2 (#219, Fusion — Lot 1)** — les assets partagés
|
||||||
|
`frontend/draco-panneau.js` / `frontend/draco-panneau.css` passent
|
||||||
|
en v2, à l'identique dans les quatre applications : réponses de
|
||||||
|
l'agent en **Markdown rendu** (HTML produit par le service
|
||||||
|
d'agents 0.10.0, sanitizé côté serveur), **largeur réglable**
|
||||||
|
persistée, **thème clair/sombre** aligné sur l'application hôte
|
||||||
|
(suivi en direct de `html[data-theme]`), et **discussions façon
|
||||||
|
Muse** — discussion principale perpétuelle et discussions
|
||||||
|
parallèles créées, archivées, supprimées depuis le tiroir du
|
||||||
|
panneau. Aucun code propre à ObsiGate n'est modifié ;
|
||||||
|
redéploiement Docker requis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.74.0] — 2026-10-22
|
## [2.74.0] — 2026-10-22
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.74.0).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.80.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.74.0 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.80.1 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.74.0).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.80.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.74.0 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.80.1 | Last updated: September 2026*
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ def _build_stats_payload() -> dict:
|
|||||||
"disk_used_gb": disk_used_gb,
|
"disk_used_gb": disk_used_gb,
|
||||||
"disk_total_gb": disk_total_gb,
|
"disk_total_gb": disk_total_gb,
|
||||||
"uptime_seconds": int(_server_uptime_seconds()),
|
"uptime_seconds": int(_server_uptime_seconds()),
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Émission d'événements vers le journal du service d'agents
|
||||||
|
(Fusion, Lot 2 vague 5 — contrat ACC-0.2, Roadmap #220).
|
||||||
|
|
||||||
|
ObsiGate émet ses mutations de notes **explicites** vers
|
||||||
|
``POST /api/v1/evenements`` du service d'agents, qui les expose dans
|
||||||
|
le journal de sa console. L'enveloppe ne contient qu'une référence,
|
||||||
|
un libellé et un lien — jamais le contenu de la note :
|
||||||
|
``{id, type, occurred_at, actor, domain, source_app,
|
||||||
|
object{ref, label, url}}``.
|
||||||
|
|
||||||
|
**Garde-fou (conception validée)** : ce module n'est appelé que
|
||||||
|
depuis les points d'écriture explicites de l'API web —
|
||||||
|
:mod:`backend.routers.files_write` (session utilisateur, acteur
|
||||||
|
``user``) et la couche outils :mod:`backend.tools.service`
|
||||||
|
(serveur MCP d'ObsiGate, acteur ``agent:mcp`` ; assistant intégré,
|
||||||
|
acteur ``user``). Le **watcher** (:mod:`backend.watcher`) et
|
||||||
|
l'indexeur ne l'appellent **jamais** : le vault principal compte
|
||||||
|
plus de 220 000 fichiers, un branchement sur les événements du
|
||||||
|
système de fichiers inonderait le journal. Une modification faite
|
||||||
|
hors API (éditeur externe, synchro disque…) reste donc invisible
|
||||||
|
du journal — c'est voulu.
|
||||||
|
|
||||||
|
Authentification : signature HMAC-SHA256 du corps brut, en-tête
|
||||||
|
``X-Draco-Signature``, clé propre à ObsiGate
|
||||||
|
(``AGENT_EVENEMENTS_CLE``, jamais en dur). Sans clé, le module est
|
||||||
|
**inactif** : aucun appel, aucun bruit. URL surchargée par
|
||||||
|
``AGENT_EVENEMENTS_URL``.
|
||||||
|
|
||||||
|
Discipline « au mieux » (même esprit que TikTak et Kanbante) :
|
||||||
|
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
|
||||||
|
et toute erreur est avalée avec une ligne de journal — une panne
|
||||||
|
du récepteur ne doit jamais casser ni ralentir le métier.
|
||||||
|
|
||||||
|
Domaine : ObsiGate ne modélise pas encore le domaine d'un vault
|
||||||
|
(question Q-A du programme Fusion toujours ouverte). La
|
||||||
|
correspondance peut être déclarée par ``AGENT_EVENEMENTS_DOMAINES``
|
||||||
|
(JSON ``{"NomDuVault": "famille"}``) ; repli ``pro``, qui est aussi
|
||||||
|
le défaut du journal du service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate.agent_journal")
|
||||||
|
|
||||||
|
SOURCE_APP = "obsigate"
|
||||||
|
# Défaut officiel, conservé pour référence : depuis #223, ``_url()``
|
||||||
|
# passe par la config centralisée (``backend.draco_config``) — jamais
|
||||||
|
# un nom d'environnement particulier.
|
||||||
|
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
|
||||||
|
DELAI_S = 2.0
|
||||||
|
|
||||||
|
TYPE_CREE = "obsigate.note.created"
|
||||||
|
TYPE_EDITE = "obsigate.note.edited"
|
||||||
|
TYPE_DEPLACE = "obsigate.note.moved"
|
||||||
|
TYPE_SUPPRIME = "obsigate.note.deleted"
|
||||||
|
|
||||||
|
ACTEUR_USER = "user"
|
||||||
|
ACTEUR_MCP = "agent:mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def _cle() -> str:
|
||||||
|
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _url() -> str:
|
||||||
|
# #223 : AGENT_EVENEMENTS_URL d'abord ; à défaut, l'URL du service
|
||||||
|
# configurée (DRACO_AGENT_URL) complétée du chemin d'événements ;
|
||||||
|
# à défaut, le défaut officiel.
|
||||||
|
explicite = os.environ.get("AGENT_EVENEMENTS_URL", "").strip()
|
||||||
|
if explicite:
|
||||||
|
return explicite
|
||||||
|
from backend.draco_config import agent_url
|
||||||
|
|
||||||
|
return f"{agent_url()}/api/v1/evenements"
|
||||||
|
|
||||||
|
|
||||||
|
def actif() -> bool:
|
||||||
|
"""L'émission n'existe que si la clé d'ObsiGate est configurée."""
|
||||||
|
return bool(_cle())
|
||||||
|
|
||||||
|
|
||||||
|
def domaine_du_vault(vault: str) -> str:
|
||||||
|
"""Domaine journal du vault : correspondance déclarée dans
|
||||||
|
``AGENT_EVENEMENTS_DOMAINES`` (JSON), repli ``pro``."""
|
||||||
|
brut = os.environ.get("AGENT_EVENEMENTS_DOMAINES", "").strip()
|
||||||
|
if brut:
|
||||||
|
try:
|
||||||
|
correspondance = json.loads(brut)
|
||||||
|
except ValueError:
|
||||||
|
correspondance = {}
|
||||||
|
if isinstance(correspondance, dict):
|
||||||
|
domaine = correspondance.get(vault)
|
||||||
|
if isinstance(domaine, str) and domaine:
|
||||||
|
return domaine
|
||||||
|
return "pro"
|
||||||
|
|
||||||
|
|
||||||
|
def lien_fichier(vault: str, chemin: str) -> str:
|
||||||
|
"""Lien profond ObsiGate vers un fichier (format ``#file=`` lu
|
||||||
|
par le frontend : ``#file=<vault>:<chemin>``, décodé en URI)."""
|
||||||
|
return f"/#file={quote(vault, safe='')}:{quote(chemin, safe='/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def construire_enveloppe(
|
||||||
|
type_: str,
|
||||||
|
vault: str,
|
||||||
|
chemin: str,
|
||||||
|
actor: str = ACTEUR_USER,
|
||||||
|
) -> tuple[dict, bytes]:
|
||||||
|
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
|
||||||
|
signé puis envoyé — la signature porte sur ces octets-là).
|
||||||
|
``chemin`` est le chemin relatif au vault ; pour un déplacement,
|
||||||
|
c'est le **nouveau** chemin (la référence pointe sur l'objet tel
|
||||||
|
qu'il existe après l'opération)."""
|
||||||
|
env = {
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"type": type_,
|
||||||
|
"occurred_at": datetime.now(UTC).isoformat(),
|
||||||
|
"actor": actor or ACTEUR_USER,
|
||||||
|
"domain": domaine_du_vault(vault),
|
||||||
|
"source_app": SOURCE_APP,
|
||||||
|
"object": {
|
||||||
|
"ref": f"fichier:{vault}/{chemin}",
|
||||||
|
"label": chemin.rsplit("/", 1)[-1] if chemin else vault,
|
||||||
|
"url": lien_fichier(vault, chemin),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
|
||||||
|
return env, corps
|
||||||
|
|
||||||
|
|
||||||
|
def _signer(corps: bytes) -> str:
|
||||||
|
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _envoyer(corps: bytes) -> None:
|
||||||
|
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
|
||||||
|
journal distant est un confort, jamais une dépendance."""
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
|
||||||
|
client.post(
|
||||||
|
_url(),
|
||||||
|
content=corps,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Draco-Signature": _signer(corps),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc: # panne, délai, DNS… : on log et on passe
|
||||||
|
logger.warning("événement non livré au journal du service (%s)", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def emettre(
|
||||||
|
type_: str,
|
||||||
|
vault: str,
|
||||||
|
chemin: str,
|
||||||
|
actor: str = ACTEUR_USER,
|
||||||
|
) -> bool:
|
||||||
|
"""Émet un événement en tâche de fond. Retourne True si l'envoi
|
||||||
|
a été déclenché, False si le module est inactif (pas de clé) ou
|
||||||
|
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
|
||||||
|
if not actif():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_env, corps = construire_enveloppe(type_, vault, chemin, actor)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("événement %s non construit", type_, exc_info=True)
|
||||||
|
return False
|
||||||
|
threading.Thread(
|
||||||
|
target=_envoyer, args=(corps,), daemon=True, name="obsigate-evenements"
|
||||||
|
).start()
|
||||||
|
return True
|
||||||
+41
-6
@@ -10,7 +10,7 @@ Format: JSON lines (one JSON object per line) for easy parsing.
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.audit")
|
logger = logging.getLogger("obsigate.audit")
|
||||||
@@ -54,7 +54,7 @@ def log_file_save(
|
|||||||
):
|
):
|
||||||
"""Log a file save (PUT) operation."""
|
"""Log a file save (PUT) operation."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "file_save",
|
"action": "file_save",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
@@ -72,7 +72,7 @@ def log_file_delete(
|
|||||||
):
|
):
|
||||||
"""Log a file delete operation."""
|
"""Log a file delete operation."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "file_delete",
|
"action": "file_delete",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
@@ -88,7 +88,7 @@ def log_config_change(
|
|||||||
):
|
):
|
||||||
"""Log a configuration change."""
|
"""Log a configuration change."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "config_change",
|
"action": "config_change",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
@@ -99,7 +99,7 @@ def log_config_change(
|
|||||||
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
|
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
|
||||||
"""Log a vault addition."""
|
"""Log a vault addition."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "vault_add",
|
"action": "vault_add",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
@@ -111,7 +111,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | Non
|
|||||||
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
||||||
"""Log a vault removal."""
|
"""Log a vault removal."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "vault_remove",
|
"action": "vault_remove",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
@@ -119,6 +119,41 @@ def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def log_coffre_event(
|
||||||
|
username: str,
|
||||||
|
action: str,
|
||||||
|
coffre: str | None = None,
|
||||||
|
secret_ref: str | None = None,
|
||||||
|
consommateur: str | None = None,
|
||||||
|
resultat: str | None = None,
|
||||||
|
contexte_id: str | None = None,
|
||||||
|
ip: str | None = None,
|
||||||
|
):
|
||||||
|
"""Journal d'un événement du coffre Secrets (Phase 1b).
|
||||||
|
|
||||||
|
Règle absolue : aucun champ ne contient jamais une valeur de secret,
|
||||||
|
un texte chiffré, un nonce ou un jeton. Seules des références
|
||||||
|
(coffre:<slug>/<nom>), des identifiants et des codes de résultat.
|
||||||
|
"""
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"action": action,
|
||||||
|
"username": username,
|
||||||
|
"ip": ip or "unknown",
|
||||||
|
}
|
||||||
|
if coffre is not None:
|
||||||
|
entry["coffre"] = coffre
|
||||||
|
if secret_ref is not None:
|
||||||
|
entry["secret_ref"] = secret_ref
|
||||||
|
if consommateur is not None:
|
||||||
|
entry["consommateur"] = consommateur
|
||||||
|
if resultat is not None:
|
||||||
|
entry["resultat"] = resultat
|
||||||
|
if contexte_id is not None:
|
||||||
|
entry["contexte_id"] = contexte_id
|
||||||
|
_write_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
|
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
|
||||||
"""Read the most recent audit log entries.
|
"""Read the most recent audit log entries.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import binascii
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||||
from pydantic import BaseModel, validator
|
from pydantic import BaseModel, validator
|
||||||
@@ -696,7 +697,7 @@ async def mfa_webauthn_register(
|
|||||||
|
|
||||||
Returns recovery codes when MFA is newly enabled (they were never issued).
|
Returns recovery codes when MFA is newly enabled (they were never issued).
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
from .user_store import get_user, update_user
|
from .user_store import get_user, update_user
|
||||||
from .webauthn_mfa import complete_registration, resolve_relying_party
|
from .webauthn_mfa import complete_registration, resolve_relying_party
|
||||||
@@ -716,7 +717,7 @@ async def mfa_webauthn_register(
|
|||||||
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
||||||
raise HTTPException(400, "Validation du credential WebAuthn échouée")
|
raise HTTPException(400, "Validation du credential WebAuthn échouée")
|
||||||
|
|
||||||
record["registered_at"] = datetime.now(timezone.utc).isoformat()
|
record["registered_at"] = datetime.now(UTC).isoformat()
|
||||||
creds = list(user.get("webauthn_credentials", []))
|
creds = list(user.get("webauthn_credentials", []))
|
||||||
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
||||||
creds.append(record)
|
creds.append(record)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .password import hash_password
|
from .password import hash_password
|
||||||
@@ -97,8 +97,8 @@ def create_user(
|
|||||||
"active": True,
|
"active": True,
|
||||||
"language": "fr", # default UI language
|
"language": "fr", # default UI language
|
||||||
"avatar": None, # profile picture data-URL (#113)
|
"avatar": None, # profile picture data-URL (#113)
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"password_changed_at": datetime.now(timezone.utc).timestamp(),
|
"password_changed_at": datetime.now(UTC).timestamp(),
|
||||||
"last_login": None,
|
"last_login": None,
|
||||||
"failed_attempts": 0,
|
"failed_attempts": 0,
|
||||||
"locked_until": None,
|
"locked_until": None,
|
||||||
@@ -126,7 +126,7 @@ def update_user(username: str, updates: dict) -> dict:
|
|||||||
if "password" in safe_updates:
|
if "password" in safe_updates:
|
||||||
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
||||||
# BUG-028: invalidate every token issued before this change.
|
# BUG-028: invalidate every token issued before this change.
|
||||||
safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp()
|
safe_updates["password_changed_at"] = datetime.now(UTC).timestamp()
|
||||||
|
|
||||||
data["users"][username].update(safe_updates)
|
data["users"][username].update(safe_updates)
|
||||||
_write(data)
|
_write(data)
|
||||||
@@ -148,7 +148,7 @@ def delete_user(username: str):
|
|||||||
def record_login_success(username: str):
|
def record_login_success(username: str):
|
||||||
"""Record a successful login: update last_login, reset failed attempts."""
|
"""Record a successful login: update last_login, reset failed attempts."""
|
||||||
update_user(username, {
|
update_user(username, {
|
||||||
"last_login": datetime.now(timezone.utc).isoformat(),
|
"last_login": datetime.now(UTC).isoformat(),
|
||||||
"failed_attempts": 0,
|
"failed_attempts": 0,
|
||||||
"locked_until": None,
|
"locked_until": None,
|
||||||
})
|
})
|
||||||
@@ -171,7 +171,7 @@ def record_login_failure(username: str) -> int:
|
|||||||
# Lock after 5 failed attempts (15 minutes)
|
# Lock after 5 failed attempts (15 minutes)
|
||||||
if attempts >= 5:
|
if attempts >= 5:
|
||||||
locked_until = (
|
locked_until = (
|
||||||
datetime.now(timezone.utc) + timedelta(minutes=15)
|
datetime.now(UTC) + timedelta(minutes=15)
|
||||||
).isoformat()
|
).isoformat()
|
||||||
updates["locked_until"] = locked_until
|
updates["locked_until"] = locked_until
|
||||||
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
||||||
@@ -189,7 +189,7 @@ def is_locked(username: str) -> bool:
|
|||||||
if not user or not user.get("locked_until"):
|
if not user or not user.get("locked_until"):
|
||||||
return False
|
return False
|
||||||
locked_until = datetime.fromisoformat(user["locked_until"])
|
locked_until = datetime.fromisoformat(user["locked_until"])
|
||||||
if datetime.now(timezone.utc) > locked_until:
|
if datetime.now(UTC) > locked_until:
|
||||||
# Lock expired — unlock
|
# Lock expired — unlock
|
||||||
update_user(username, {"locked_until": None, "failed_attempts": 0})
|
update_user(username, {"locked_until": None, "failed_attempts": 0})
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -591,7 +591,7 @@ async def api_bookslm_agent(
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
|
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
if run_task.done():
|
if run_task.done():
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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()
|
||||||
|
return encrypt_with_key(dek, valeur.encode("utf-8"), aad)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret_value(
|
||||||
|
dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str
|
||||||
|
) -> str:
|
||||||
|
"""Déchiffre une valeur de secret."""
|
||||||
|
aad = f"{secret_id}:{version}".encode()
|
||||||
|
return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def reencrypt_secret_value(
|
||||||
|
ancienne_dek: bytes,
|
||||||
|
nouvelle_dek: bytes,
|
||||||
|
secret_id: str,
|
||||||
|
version: int,
|
||||||
|
nonce_b64: str,
|
||||||
|
ciphertext_b64: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Re-chiffre une version avec une nouvelle DEK (rotation DEK)."""
|
||||||
|
valeur = decrypt_secret_value(
|
||||||
|
ancienne_dek, secret_id, version, nonce_b64, ciphertext_b64
|
||||||
|
)
|
||||||
|
return encrypt_secret_value(nouvelle_dek, secret_id, version, valeur)
|
||||||
@@ -0,0 +1,859 @@
|
|||||||
|
"""Store du coffre Secrets ObsiGate (Phase 1a).
|
||||||
|
|
||||||
|
Patron repris de ``backend/tools/secrets.py`` : JSON dans ``data/``,
|
||||||
|
écriture atomique (tmp + rename), verrou ``RLock`` sur les cycles
|
||||||
|
lecture-modification-écriture, répertoire honorant ``OBSIGATE_DATA_DIR``.
|
||||||
|
|
||||||
|
Fichiers (permissions 0600) :
|
||||||
|
- ``data/coffres.json`` : coffres (métadonnées + DEK chiffrées),
|
||||||
|
consommateurs (jetons hachés SHA-256 seulement, jamais en clair),
|
||||||
|
octrois (références, statuts, expirations).
|
||||||
|
- ``data/coffres_valeurs.json`` : versions chiffrées des secrets.
|
||||||
|
|
||||||
|
Aucune valeur en clair n'est jamais persistée ici, et aucune fonction de
|
||||||
|
ce module ne journalise de valeur ni de texte chiffré.
|
||||||
|
|
||||||
|
Modèle :
|
||||||
|
- Coffre : id, slug, nom, description, proprietaire, perimetre
|
||||||
|
(personnel | partage | instance), dek_chiffree (ou None en mode
|
||||||
|
dégradé sans KEK), statut (actif | archive), dates.
|
||||||
|
- Secret : id, coffre_id, nom, type, service, note, expiration,
|
||||||
|
version_courante, cree_le, modifie_le, derniere_utilisation.
|
||||||
|
- Version : numero, nonce, ciphertext, cree_le, remplacee_le.
|
||||||
|
Seule la version courante est distribuable.
|
||||||
|
- Consommateur : id (sert de jti), nom, jeton_hash, jeton_prefixe,
|
||||||
|
statut (actif | revoque), cree_par, cree_le.
|
||||||
|
- Octroi : id, coffre_id, secret_id (None = tout le coffre),
|
||||||
|
cible_type (consommateur | utilisateur | executeur | workspace),
|
||||||
|
cible_id, expiration, statut (actif | revoque | expire),
|
||||||
|
cree_par, cree_le, revoque_le.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets as _secrets
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.coffre_crypto import (
|
||||||
|
decrypt_secret_value,
|
||||||
|
encrypt_secret_value,
|
||||||
|
generate_dek,
|
||||||
|
load_kek,
|
||||||
|
parse_kek,
|
||||||
|
reencrypt_secret_value,
|
||||||
|
unwrap_dek,
|
||||||
|
wrap_dek,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate.coffre_store")
|
||||||
|
|
||||||
|
_lock = threading.RLock()
|
||||||
|
|
||||||
|
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
|
||||||
|
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
|
||||||
|
TYPES_SECRET = {"api_key", "token", "password", "certificate", "autre"}
|
||||||
|
PERIMETRES = {"personnel", "partage", "instance"}
|
||||||
|
CIBLES_OCTROI = {"consommateur", "utilisateur", "executeur", "workspace"}
|
||||||
|
|
||||||
|
MAX_VALEUR = 65536
|
||||||
|
MAX_TEXTE = 2000
|
||||||
|
DUREE_OCTROI_DEFAUT_JOURS = 90
|
||||||
|
|
||||||
|
# Motifs refusés dans les champs de métadonnées (description, note) :
|
||||||
|
# une valeur ressemblant à un secret ne doit jamais transiter par un champ
|
||||||
|
# qui, lui, est renvoyé en clair dans les métadonnées.
|
||||||
|
_SECRET_LIKE_PATTERNS = (
|
||||||
|
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
||||||
|
re.compile(r"\bsk-[A-Za-z0-9]{8,}\b"),
|
||||||
|
re.compile(r"\bgh[opsu]_[A-Za-z0-9]{8,}\b"),
|
||||||
|
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\b(token|secret|mot_de_passe|password|api[-_]?key)\s*[:=]\s*\S+"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StoreError(Exception):
|
||||||
|
"""Erreur générique du store."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreExisteError(StoreError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SecretExisteError(StoreError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntrouvableError(StoreError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(StoreError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SecretExpireError(StoreError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_data_dir(data_dir: str | Path | None = None) -> Path:
|
||||||
|
"""Répertoire de persistance (OBSIGATE_DATA_DIR, défaut ``data``)."""
|
||||||
|
if data_dir is not None:
|
||||||
|
return Path(data_dir)
|
||||||
|
return Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
|
||||||
|
|
||||||
|
|
||||||
|
def _coffres_path(base: Path) -> Path:
|
||||||
|
return base / "coffres.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _valeurs_path(base: Path) -> Path:
|
||||||
|
return base / "coffres_valeurs.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str | None) -> datetime | None:
|
||||||
|
"""Parse une date ISO (datetime) ou une date seule (fin de journée UTC)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if len(text) == 10:
|
||||||
|
text = text + "T23:59:59+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValidationError(f"Date invalide : {value!r}") from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def est_expire(expiration: str | None, maintenant: datetime | None = None) -> bool:
|
||||||
|
"""True si la date d'expiration est passée (None = jamais)."""
|
||||||
|
parsed = _parse_date(expiration)
|
||||||
|
if parsed is None:
|
||||||
|
return False
|
||||||
|
ref = maintenant or datetime.now(UTC)
|
||||||
|
return parsed <= ref
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path, default: dict) -> dict:
|
||||||
|
if not path.exists():
|
||||||
|
return json.loads(json.dumps(default))
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
logger.warning("Store coffre illisible (%s) : %s", path, exc)
|
||||||
|
return json.loads(json.dumps(default))
|
||||||
|
return data if isinstance(data, dict) else json.loads(json.dumps(default))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, data: dict) -> None:
|
||||||
|
"""Écriture atomique tmp+rename, fichier final en 0600."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.replace(tmp, path)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _load_coffres(base: Path) -> dict:
|
||||||
|
return _read_json(
|
||||||
|
_coffres_path(base),
|
||||||
|
{"coffres": [], "consommateurs": [], "octrois": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_valeurs(base: Path) -> dict:
|
||||||
|
return _read_json(_valeurs_path(base), {"versions": {}})
|
||||||
|
|
||||||
|
|
||||||
|
def _save_coffres(base: Path, data: dict) -> None:
|
||||||
|
_write_json(_coffres_path(base), data)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_valeurs(base: Path, data: dict) -> None:
|
||||||
|
_write_json(_valeurs_path(base), data)
|
||||||
|
|
||||||
|
|
||||||
|
def valider_slug(slug: str) -> str:
|
||||||
|
value = (slug or "").strip().lower()
|
||||||
|
if not SLUG_RE.match(value):
|
||||||
|
raise ValidationError(
|
||||||
|
"Slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def valider_nom_secret(nom: str) -> str:
|
||||||
|
value = (nom or "").strip().lower()
|
||||||
|
if not NOM_SECRET_RE.match(value):
|
||||||
|
raise ValidationError(
|
||||||
|
"Nom de secret invalide : 3 à 128 caractères, minuscules, chiffres, - ou _"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def refuser_valeur_dans_metadonnees(texte: str | None, champ: str) -> None:
|
||||||
|
"""Refuse une valeur ressemblant à un secret dans un champ métadonnée."""
|
||||||
|
if not texte:
|
||||||
|
return
|
||||||
|
for motif in _SECRET_LIKE_PATTERNS:
|
||||||
|
if motif.search(texte):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Le champ {champ} ressemble à une valeur de secret ; "
|
||||||
|
"utilisez le champ valeur du secret, jamais une métadonnée"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valider_texte(texte: str | None, champ: str) -> str:
|
||||||
|
value = (texte or "").strip()
|
||||||
|
if len(value) > MAX_TEXTE:
|
||||||
|
raise ValidationError(f"Le champ {champ} dépasse {MAX_TEXTE} caractères")
|
||||||
|
refuser_valeur_dans_metadonnees(value, champ)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Coffres
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_coffres(data_dir: str | Path | None = None) -> list:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
return list(_load_coffres(base)["coffres"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_coffre(slug: str, data_dir: str | Path | None = None) -> dict | None:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
slug = valider_slug(slug)
|
||||||
|
with _lock:
|
||||||
|
for coffre in _load_coffres(base)["coffres"]:
|
||||||
|
if coffre["slug"] == slug:
|
||||||
|
return coffre
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_coffre_or_404(slug: str, data_dir: str | Path | None = None) -> dict:
|
||||||
|
coffre = get_coffre(slug, data_dir)
|
||||||
|
if coffre is None:
|
||||||
|
raise IntrouvableError(f"Coffre introuvable : {slug}")
|
||||||
|
return coffre
|
||||||
|
|
||||||
|
|
||||||
|
def get_coffre_by_id(coffre_id: str, data_dir: str | Path | None = None) -> dict | None:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
for coffre in _load_coffres(base)["coffres"]:
|
||||||
|
if coffre["id"] == coffre_id:
|
||||||
|
return coffre
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict | None:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
for octroi in _load_coffres(base)["octrois"]:
|
||||||
|
if octroi["id"] == octroi_id:
|
||||||
|
return octroi
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_coffre(
|
||||||
|
slug: str,
|
||||||
|
nom: str,
|
||||||
|
proprietaire: str,
|
||||||
|
perimetre: str = "personnel",
|
||||||
|
description: str = "",
|
||||||
|
data_dir: str | Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Crée un coffre. Sans KEK, la DEK reste None (mode dégradé) : les
|
||||||
|
métadonnées sont créées, mais aucun secret ne pourra être ajouté tant
|
||||||
|
que la KEK n'est pas configurée."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
slug = valider_slug(slug)
|
||||||
|
if perimetre not in PERIMETRES:
|
||||||
|
raise ValidationError(f"Périmètre invalide : {perimetre}")
|
||||||
|
if not (proprietaire or "").strip():
|
||||||
|
raise ValidationError("Propriétaire requis")
|
||||||
|
description = _valider_texte(description, "description")
|
||||||
|
nom = (nom or "").strip()
|
||||||
|
if not nom or len(nom) > 128:
|
||||||
|
raise ValidationError("Nom de coffre requis (128 caractères max)")
|
||||||
|
with _lock:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
if any(c["slug"] == slug for c in data["coffres"]):
|
||||||
|
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
|
||||||
|
coffre = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"slug": slug,
|
||||||
|
"nom": nom,
|
||||||
|
"description": description,
|
||||||
|
"proprietaire": proprietaire.strip(),
|
||||||
|
"perimetre": perimetre,
|
||||||
|
"dek_chiffree": None,
|
||||||
|
"statut": "actif",
|
||||||
|
"cree_le": _now_iso(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
kek = load_kek()
|
||||||
|
except Exception:
|
||||||
|
kek = None
|
||||||
|
if kek is not None:
|
||||||
|
coffre["dek_chiffree"] = wrap_dek(generate_dek(), kek, coffre["id"])
|
||||||
|
data["coffres"].append(coffre)
|
||||||
|
_save_coffres(base, data)
|
||||||
|
return coffre
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dek(coffre: dict, data_dir: str | Path | None = None) -> bytes:
|
||||||
|
"""Retourne la DEK en clair (mémoire seulement). Génère et persiste la
|
||||||
|
DEK si le coffre a été créé en mode dégradé et que la KEK est là."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
kek = load_kek() # lève KEKAbsenteError si aucune KEK
|
||||||
|
if coffre.get("dek_chiffree"):
|
||||||
|
return unwrap_dek(coffre["dek_chiffree"], kek, coffre["id"])
|
||||||
|
with _lock:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
for item in data["coffres"]:
|
||||||
|
if item["id"] == coffre["id"]:
|
||||||
|
if item.get("dek_chiffree"):
|
||||||
|
return unwrap_dek(item["dek_chiffree"], kek, item["id"])
|
||||||
|
dek = generate_dek()
|
||||||
|
item["dek_chiffree"] = wrap_dek(dek, kek, item["id"])
|
||||||
|
_save_coffres(base, data)
|
||||||
|
return dek
|
||||||
|
raise IntrouvableError("Coffre introuvable pour génération de DEK")
|
||||||
|
|
||||||
|
|
||||||
|
# Les secrets vivent dans coffres.json (métadonnées), leurs versions dans
|
||||||
|
# coffres_valeurs.json (chiffrées). Les deux chargements sont faits ici
|
||||||
|
# pour garder une seule source de vérité dans ce module.
|
||||||
|
def _load_tout(base: Path) -> tuple[dict, dict]:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
if "secrets" not in data:
|
||||||
|
data["secrets"] = []
|
||||||
|
return data, _load_valeurs(base)
|
||||||
|
|
||||||
|
|
||||||
|
def compter_secrets(coffre_id: str, data_dir: str | Path | None = None) -> int:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
data, _ = _load_tout(base)
|
||||||
|
return sum(1 for s in data["secrets"] if s["coffre_id"] == coffre_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_secrets(coffre_id: str, data_dir: str | Path | None = None) -> list:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
data, _ = _load_tout(base)
|
||||||
|
return [s for s in data["secrets"] if s["coffre_id"] == coffre_id]
|
||||||
|
|
||||||
|
|
||||||
|
def get_secret(
|
||||||
|
coffre_id: str, nom: str, data_dir: str | Path | None = None
|
||||||
|
) -> dict | None:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
nom = valider_nom_secret(nom)
|
||||||
|
with _lock:
|
||||||
|
data, _ = _load_tout(base)
|
||||||
|
for secret in data["secrets"]:
|
||||||
|
if secret["coffre_id"] == coffre_id and secret["nom"] == nom:
|
||||||
|
return secret
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def secret_metadata(secret: dict, octrois_actifs: int = 0) -> dict:
|
||||||
|
"""Vue métadonnées seules d'un secret : jamais de valeur, jamais de
|
||||||
|
texte chiffré, jamais de nonce."""
|
||||||
|
return {
|
||||||
|
"id": secret["id"],
|
||||||
|
"nom": secret["nom"],
|
||||||
|
"type": secret["type"],
|
||||||
|
"service": secret.get("service", ""),
|
||||||
|
"note": secret.get("note", ""),
|
||||||
|
"expiration": secret.get("expiration"),
|
||||||
|
"expire": est_expire(secret.get("expiration")),
|
||||||
|
"version": secret["version_courante"],
|
||||||
|
"has_value": True,
|
||||||
|
"cree_le": secret["cree_le"],
|
||||||
|
"modifie_le": secret["modifie_le"],
|
||||||
|
"derniere_utilisation": secret.get("derniere_utilisation"),
|
||||||
|
"octrois_actifs": octrois_actifs,
|
||||||
|
"reference": f"coffre:{secret['coffre_slug']}/{secret['nom']}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_secret(
|
||||||
|
coffre: dict,
|
||||||
|
nom: str,
|
||||||
|
type_secret: str,
|
||||||
|
valeur: str,
|
||||||
|
service: str = "",
|
||||||
|
expiration: str | None = None,
|
||||||
|
note: str = "",
|
||||||
|
data_dir: str | Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Chiffre et stocke un secret (nouvelle version 1)."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
nom = valider_nom_secret(nom)
|
||||||
|
if type_secret not in TYPES_SECRET:
|
||||||
|
raise ValidationError(f"Type de secret invalide : {type_secret}")
|
||||||
|
if not valeur or len(valeur) > MAX_VALEUR:
|
||||||
|
raise ValidationError("Valeur requise (64 KiB max)")
|
||||||
|
service = (service or "").strip()
|
||||||
|
if len(service) > 128:
|
||||||
|
raise ValidationError("Service trop long (128 caractères max)")
|
||||||
|
note = _valider_texte(note, "note")
|
||||||
|
if expiration is not None:
|
||||||
|
_parse_date(expiration)
|
||||||
|
dek = ensure_dek(coffre, base) # KEKAbsenteError si pas de KEK
|
||||||
|
with _lock:
|
||||||
|
data, valeurs = _load_tout(base)
|
||||||
|
if any(
|
||||||
|
s["coffre_id"] == coffre["id"] and s["nom"] == nom
|
||||||
|
for s in data["secrets"]
|
||||||
|
):
|
||||||
|
raise SecretExisteError(f"Secret déjà existant : {nom}")
|
||||||
|
secret_id = str(uuid.uuid4())
|
||||||
|
chiffre = encrypt_secret_value(dek, secret_id, 1, valeur)
|
||||||
|
secret = {
|
||||||
|
"id": secret_id,
|
||||||
|
"coffre_id": coffre["id"],
|
||||||
|
"coffre_slug": coffre["slug"],
|
||||||
|
"nom": nom,
|
||||||
|
"type": type_secret,
|
||||||
|
"service": service,
|
||||||
|
"note": note,
|
||||||
|
"expiration": expiration,
|
||||||
|
"version_courante": 1,
|
||||||
|
"cree_le": _now_iso(),
|
||||||
|
"modifie_le": _now_iso(),
|
||||||
|
"derniere_utilisation": None,
|
||||||
|
}
|
||||||
|
data["secrets"].append(secret)
|
||||||
|
valeurs["versions"][secret_id] = [
|
||||||
|
{
|
||||||
|
"numero": 1,
|
||||||
|
"nonce": chiffre["nonce"],
|
||||||
|
"ciphertext": chiffre["ciphertext"],
|
||||||
|
"cree_le": secret["cree_le"],
|
||||||
|
"remplacee_le": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
_save_coffres(base, data)
|
||||||
|
_save_valeurs(base, valeurs)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_secret(
|
||||||
|
coffre: dict, nom: str, valeur: str, data_dir: str | Path | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Rotation : nouvelle version active immédiatement, l'ancienne n'est
|
||||||
|
plus distribuable (seule la version courante est lue à la distribution)."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
nom = valider_nom_secret(nom)
|
||||||
|
if not valeur or len(valeur) > MAX_VALEUR:
|
||||||
|
raise ValidationError("Valeur requise (64 KiB max)")
|
||||||
|
dek = ensure_dek(coffre, base)
|
||||||
|
with _lock:
|
||||||
|
data, valeurs = _load_tout(base)
|
||||||
|
secret = None
|
||||||
|
for item in data["secrets"]:
|
||||||
|
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
|
||||||
|
secret = item
|
||||||
|
break
|
||||||
|
if secret is None:
|
||||||
|
raise IntrouvableError(f"Secret introuvable : {nom}")
|
||||||
|
nouvelle_version = secret["version_courante"] + 1
|
||||||
|
chiffre = encrypt_secret_value(dek, secret["id"], nouvelle_version, valeur)
|
||||||
|
versions = valeurs["versions"].setdefault(secret["id"], [])
|
||||||
|
for version in versions:
|
||||||
|
if version["remplacee_le"] is None:
|
||||||
|
version["remplacee_le"] = _now_iso()
|
||||||
|
versions.append(
|
||||||
|
{
|
||||||
|
"numero": nouvelle_version,
|
||||||
|
"nonce": chiffre["nonce"],
|
||||||
|
"ciphertext": chiffre["ciphertext"],
|
||||||
|
"cree_le": _now_iso(),
|
||||||
|
"remplacee_le": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
secret["version_courante"] = nouvelle_version
|
||||||
|
secret["modifie_le"] = _now_iso()
|
||||||
|
_save_coffres(base, data)
|
||||||
|
_save_valeurs(base, valeurs)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def delete_secret(
|
||||||
|
coffre: dict, nom: str, data_dir: str | Path | None = None
|
||||||
|
) -> tuple[dict, list]:
|
||||||
|
"""Supprime un secret, ses versions et ses octrois (cascade)."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
nom = valider_nom_secret(nom)
|
||||||
|
with _lock:
|
||||||
|
data, valeurs = _load_tout(base)
|
||||||
|
secret = None
|
||||||
|
for item in data["secrets"]:
|
||||||
|
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
|
||||||
|
secret = item
|
||||||
|
break
|
||||||
|
if secret is None:
|
||||||
|
raise IntrouvableError(f"Secret introuvable : {nom}")
|
||||||
|
data["secrets"] = [s for s in data["secrets"] if s["id"] != secret["id"]]
|
||||||
|
valeurs["versions"].pop(secret["id"], None)
|
||||||
|
octrois_supprimes = [
|
||||||
|
o for o in data["octrois"] if o.get("secret_id") == secret["id"]
|
||||||
|
]
|
||||||
|
data["octrois"] = [
|
||||||
|
o for o in data["octrois"] if o.get("secret_id") != secret["id"]
|
||||||
|
]
|
||||||
|
_save_coffres(base, data)
|
||||||
|
_save_valeurs(base, valeurs)
|
||||||
|
return secret, octrois_supprimes
|
||||||
|
|
||||||
|
|
||||||
|
def distribuer_valeur(
|
||||||
|
coffre: dict, secret: dict, data_dir: str | Path | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Déchiffre la version courante d'un secret (après contrôles d'octroi
|
||||||
|
faits par le routeur). Refuse un secret expiré (410 côté routeur)."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
if est_expire(secret.get("expiration")):
|
||||||
|
raise SecretExpireError("Secret expiré")
|
||||||
|
dek = ensure_dek(coffre, base)
|
||||||
|
with _lock:
|
||||||
|
_, valeurs = _load_tout(base)
|
||||||
|
versions = valeurs["versions"].get(secret["id"], [])
|
||||||
|
courante = None
|
||||||
|
for version in versions:
|
||||||
|
if version["numero"] == secret["version_courante"]:
|
||||||
|
courante = version
|
||||||
|
break
|
||||||
|
if courante is None:
|
||||||
|
raise IntrouvableError("Version courante introuvable")
|
||||||
|
return decrypt_secret_value(
|
||||||
|
dek,
|
||||||
|
secret["id"],
|
||||||
|
secret["version_courante"],
|
||||||
|
courante["nonce"],
|
||||||
|
courante["ciphertext"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def toucher_utilisation(secret_id: str, data_dir: str | Path | None = None) -> None:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
data, _ = _load_tout(base)
|
||||||
|
for secret in data["secrets"]:
|
||||||
|
if secret["id"] == secret_id:
|
||||||
|
secret["derniere_utilisation"] = _now_iso()
|
||||||
|
_save_coffres(base, data)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_dek(coffre_slug: str, data_dir: str | Path | None = None) -> dict:
|
||||||
|
"""Rotation DEK : re-chiffre toutes les versions du coffre avec une
|
||||||
|
nouvelle DEK, puis remplace la DEK chiffrée du coffre."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
coffre = get_coffre_or_404(coffre_slug, base)
|
||||||
|
kek = load_kek()
|
||||||
|
ancienne_dek = ensure_dek(coffre, base)
|
||||||
|
nouvelle_dek = generate_dek()
|
||||||
|
with _lock:
|
||||||
|
data, valeurs = _load_tout(base)
|
||||||
|
secrets_coffre = [s for s in data["secrets"] if s["coffre_id"] == coffre["id"]]
|
||||||
|
for secret in secrets_coffre:
|
||||||
|
nouvelles_versions = []
|
||||||
|
for version in valeurs["versions"].get(secret["id"], []):
|
||||||
|
nouvelles_versions.append(
|
||||||
|
{
|
||||||
|
**reencrypt_secret_value(
|
||||||
|
ancienne_dek,
|
||||||
|
nouvelle_dek,
|
||||||
|
secret["id"],
|
||||||
|
version["numero"],
|
||||||
|
version["nonce"],
|
||||||
|
version["ciphertext"],
|
||||||
|
),
|
||||||
|
"numero": version["numero"],
|
||||||
|
"cree_le": version["cree_le"],
|
||||||
|
"remplacee_le": version["remplacee_le"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
valeurs["versions"][secret["id"]] = nouvelles_versions
|
||||||
|
for item in data["coffres"]:
|
||||||
|
if item["id"] == coffre["id"]:
|
||||||
|
item["dek_chiffree"] = wrap_dek(nouvelle_dek, kek, item["id"])
|
||||||
|
coffre = item
|
||||||
|
break
|
||||||
|
_save_coffres(base, data)
|
||||||
|
_save_valeurs(base, valeurs)
|
||||||
|
return coffre
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_kek(nouvelle_kek_b64: str, data_dir: str | Path | None = None) -> int:
|
||||||
|
"""Rotation KEK : re-chiffre uniquement les DEK avec la nouvelle KEK.
|
||||||
|
L'opérateur doit ensuite remplacer OBSIGATE_KEK / OBSIGATE_KEK_FILE
|
||||||
|
par la nouvelle valeur (cette fonction ne touche pas à l'environnement).
|
||||||
|
Retourne le nombre de coffres re-chiffrés."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
ancienne_kek = load_kek()
|
||||||
|
nouvelle_kek = parse_kek(nouvelle_kek_b64)
|
||||||
|
count = 0
|
||||||
|
with _lock:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
for coffre in data["coffres"]:
|
||||||
|
if not coffre.get("dek_chiffree"):
|
||||||
|
continue
|
||||||
|
dek = unwrap_dek(coffre["dek_chiffree"], ancienne_kek, coffre["id"])
|
||||||
|
coffre["dek_chiffree"] = wrap_dek(dek, nouvelle_kek, coffre["id"])
|
||||||
|
count += 1
|
||||||
|
_save_coffres(base, data)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Consommateurs (jetons serveur-à-serveur, hachés, révocables par jti)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _hash_jeton(jeton: str) -> str:
|
||||||
|
return hashlib.sha256(jeton.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def list_consommateurs(data_dir: str | Path | None = None) -> list:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
return [
|
||||||
|
{k: v for k, v in c.items() if k != "jeton_hash"}
|
||||||
|
for c in _load_coffres(base)["consommateurs"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_consommateur(
|
||||||
|
nom: str, cree_par: str, data_dir: str | Path | None = None
|
||||||
|
) -> tuple[dict, str]:
|
||||||
|
"""Crée un consommateur et retourne (métadonnées, jeton à usage unique).
|
||||||
|
Le jeton n'est JAMAIS persisté : seul son hash SHA-256 est stocké."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
nom = (nom or "").strip()
|
||||||
|
if not nom or len(nom) > 128:
|
||||||
|
raise ValidationError("Nom de consommateur requis (128 caractères max)")
|
||||||
|
jeton = "csk_" + _secrets.token_urlsafe(32)
|
||||||
|
with _lock:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
consommateur = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"nom": nom,
|
||||||
|
"jeton_hash": _hash_jeton(jeton),
|
||||||
|
"jeton_prefixe": jeton[:12],
|
||||||
|
"statut": "actif",
|
||||||
|
"cree_par": cree_par,
|
||||||
|
"cree_le": _now_iso(),
|
||||||
|
}
|
||||||
|
data["consommateurs"].append(consommateur)
|
||||||
|
_save_coffres(base, data)
|
||||||
|
public = {k: v for k, v in consommateur.items() if k != "jeton_hash"}
|
||||||
|
return public, jeton
|
||||||
|
|
||||||
|
|
||||||
|
def verifier_jeton_consommateur(
|
||||||
|
jeton: str, data_dir: str | Path | None = None
|
||||||
|
) -> dict | None:
|
||||||
|
"""Retourne le consommateur (sans hash) si le jeton correspond, sinon None."""
|
||||||
|
if not jeton:
|
||||||
|
return None
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
cible = _hash_jeton(jeton)
|
||||||
|
with _lock:
|
||||||
|
for consommateur in _load_coffres(base)["consommateurs"]:
|
||||||
|
if hmac.compare_digest(consommateur.get("jeton_hash", ""), cible):
|
||||||
|
return {
|
||||||
|
k: v for k, v in consommateur.items() if k != "jeton_hash"
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def revoquer_consommateur(
|
||||||
|
consommateur_id: str, data_dir: str | Path | None = None
|
||||||
|
) -> dict:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
for consommateur in data["consommateurs"]:
|
||||||
|
if consommateur["id"] == consommateur_id:
|
||||||
|
consommateur["statut"] = "revoque"
|
||||||
|
_save_coffres(base, data)
|
||||||
|
return {
|
||||||
|
k: v
|
||||||
|
for k, v in consommateur.items()
|
||||||
|
if k != "jeton_hash"
|
||||||
|
}
|
||||||
|
raise IntrouvableError("Consommateur introuvable")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Octrois
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_octrois(coffre_id: str, data_dir: str | Path | None = None) -> list:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
return [
|
||||||
|
o for o in _load_coffres(base)["octrois"] if o["coffre_id"] == coffre_id
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def compter_octrois_actifs(
|
||||||
|
secret_id: str, data_dir: str | Path | None = None
|
||||||
|
) -> int:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for o in _load_coffres(base)["octrois"]
|
||||||
|
if o.get("secret_id") == secret_id
|
||||||
|
and o["statut"] == "actif"
|
||||||
|
and not est_expire(o.get("expiration"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_octroi(
|
||||||
|
coffre: dict,
|
||||||
|
cible_type: str,
|
||||||
|
cible_id: str,
|
||||||
|
secret_id: str | None,
|
||||||
|
cree_par: str,
|
||||||
|
expiration: str | None = None,
|
||||||
|
data_dir: str | Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
if cible_type not in CIBLES_OCTROI:
|
||||||
|
raise ValidationError(f"Type de cible invalide : {cible_type}")
|
||||||
|
if not (cible_id or "").strip():
|
||||||
|
raise ValidationError("Cible d'octroi requise")
|
||||||
|
if secret_id is not None:
|
||||||
|
with _lock:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
if not any(
|
||||||
|
s["id"] == secret_id and s["coffre_id"] == coffre["id"]
|
||||||
|
for s in data["secrets"]
|
||||||
|
):
|
||||||
|
raise IntrouvableError("Secret introuvable dans ce coffre")
|
||||||
|
if expiration is None:
|
||||||
|
expiration = (datetime.now(UTC) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat()
|
||||||
|
else:
|
||||||
|
_parse_date(expiration)
|
||||||
|
with _lock:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
octroi = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"coffre_id": coffre["id"],
|
||||||
|
"secret_id": secret_id,
|
||||||
|
"cible_type": cible_type,
|
||||||
|
"cible_id": cible_id.strip(),
|
||||||
|
"expiration": expiration,
|
||||||
|
"statut": "actif",
|
||||||
|
"cree_par": cree_par,
|
||||||
|
"cree_le": _now_iso(),
|
||||||
|
"revoque_le": None,
|
||||||
|
}
|
||||||
|
data["octrois"].append(octroi)
|
||||||
|
_save_coffres(base, data)
|
||||||
|
return octroi
|
||||||
|
|
||||||
|
|
||||||
|
def revoquer_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict:
|
||||||
|
"""Révocation : statut -> revoque (conservé pour distinguer
|
||||||
|
octroi_revoque de octroi_absent à la distribution)."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
data = _load_coffres(base)
|
||||||
|
for octroi in data["octrois"]:
|
||||||
|
if octroi["id"] == octroi_id:
|
||||||
|
octroi["statut"] = "revoque"
|
||||||
|
octroi["revoque_le"] = _now_iso()
|
||||||
|
_save_coffres(base, data)
|
||||||
|
return octroi
|
||||||
|
raise IntrouvableError("Octroi introuvable")
|
||||||
|
|
||||||
|
|
||||||
|
def statut_octroi_consommateur(
|
||||||
|
consommateur_id: str,
|
||||||
|
coffre_id: str,
|
||||||
|
secret_id: str,
|
||||||
|
data_dir: str | Path | None = None,
|
||||||
|
) -> tuple[str, dict | None]:
|
||||||
|
"""Statut d'accès d'un consommateur à un secret.
|
||||||
|
|
||||||
|
Retourne ("ok" | "revoque" | "expire" | "absent", octroi). Un octroi
|
||||||
|
coffre-wide (secret_id None) couvre tous les secrets du coffre.
|
||||||
|
Priorité : ok > revoque > expire > absent (un octroi révoqué ne doit
|
||||||
|
jamais être masqué par l'absence d'un autre octroi)."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
candidats = [
|
||||||
|
o
|
||||||
|
for o in _load_coffres(base)["octrois"]
|
||||||
|
if o["cible_type"] == "consommateur"
|
||||||
|
and o["cible_id"] == consommateur_id
|
||||||
|
and o["coffre_id"] == coffre_id
|
||||||
|
and (o.get("secret_id") is None or o["secret_id"] == secret_id)
|
||||||
|
]
|
||||||
|
for octroi in candidats:
|
||||||
|
if octroi["statut"] == "actif" and not est_expire(octroi.get("expiration")):
|
||||||
|
return "ok", octroi
|
||||||
|
for octroi in candidats:
|
||||||
|
if octroi["statut"] == "revoque":
|
||||||
|
return "revoque", octroi
|
||||||
|
for octroi in candidats:
|
||||||
|
if octroi["statut"] == "actif" and est_expire(octroi.get("expiration")):
|
||||||
|
return "expire", octroi
|
||||||
|
return "absent", None
|
||||||
|
|
||||||
|
|
||||||
|
def secrets_accessibles_utilisateur(
|
||||||
|
username: str, coffre: dict, data_dir: str | Path | None = None
|
||||||
|
) -> set | None:
|
||||||
|
"""Secrets d'un coffre visibles par un utilisateur via octroi.
|
||||||
|
|
||||||
|
Retourne None si un octroi coffre-wide actif existe (tout le coffre),
|
||||||
|
sinon l'ensemble des ids de secrets octroyés, sinon l'ensemble vide."""
|
||||||
|
base = resolve_data_dir(data_dir)
|
||||||
|
with _lock:
|
||||||
|
octrois = [
|
||||||
|
o
|
||||||
|
for o in _load_coffres(base)["octrois"]
|
||||||
|
if o["cible_type"] == "utilisateur"
|
||||||
|
and o["cible_id"] == username
|
||||||
|
and o["coffre_id"] == coffre["id"]
|
||||||
|
and o["statut"] == "actif"
|
||||||
|
and not est_expire(o.get("expiration"))
|
||||||
|
]
|
||||||
|
if any(o.get("secret_id") is None for o in octrois):
|
||||||
|
return None
|
||||||
|
return {o["secret_id"] for o in octrois if o.get("secret_id")}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""URLs publiques de l'écosystème dracodev — par configuration (#223).
|
||||||
|
|
||||||
|
Règle dev/prod (Bruno, 2026-10-25) : **aucun nom public de
|
||||||
|
l'écosystème n'est figé dans le code vivant**. Chaque URL publique
|
||||||
|
vient de l'environnement ; les valeurs par défaut sont les noms
|
||||||
|
**officiels** (sans suffixe) de la future production. L'environnement
|
||||||
|
dev déclare ses noms en ``-dev`` dans son seul ``.env`` — le code
|
||||||
|
n'en connaît aucun.
|
||||||
|
|
||||||
|
Variables (voir ``.env.example``) :
|
||||||
|
|
||||||
|
- ``DRACO_AGENT_URL`` — base publique du service d'agents ;
|
||||||
|
à défaut, l'origine est **dérivée** de ``AGENT_EVENEMENTS_URL``
|
||||||
|
(la configuration réelle de l'émission d'événements, #220), puis
|
||||||
|
le défaut officiel.
|
||||||
|
- ``DRACO_OBSIGATE_URL`` — base publique d'ObsiGate (liens profonds).
|
||||||
|
- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck.
|
||||||
|
- ``DRACO_TIKTAK_URL`` — base publique de TikTak.
|
||||||
|
- ``DRACO_KANBANTE_URL`` — base publique de Kanbante.
|
||||||
|
- ``DRACO_GITEA_URL`` — base publique de Gitea (les cartes
|
||||||
|
Kanbante *sont* des issues Gitea, contrat liens-profonds) ;
|
||||||
|
distincte de ``OBSIGATE_GITEA_URL`` (source connectée de
|
||||||
|
l'assistant, réglable depuis l'UI, pas une URL d'affichage).
|
||||||
|
|
||||||
|
Résolution d'une URL sœur (BUG-120) : la variable explicite prime ;
|
||||||
|
à défaut, l'**environnement est dérivé de l'hôte de l'agent** tel
|
||||||
|
qu'ObsiGate le connaît déjà (:func:`agent_url`) — si cet hôte est de
|
||||||
|
la forme ``agent<SUFFIXE>.dracodev.net`` (``-dev`` en dev, vide en
|
||||||
|
prod), l'URL sœur est ``https://<slug><SUFFIXE>.dracodev.net``. Un
|
||||||
|
hôte hors motif (autre domaine, LAN, IP, port) ne dérive **rien** :
|
||||||
|
on ne fabrique jamais un lien deviné, le défaut officiel s'applique.
|
||||||
|
L'installation pointe donc les bons liens selon son environnement
|
||||||
|
sans déclarer les quatre ``DRACO_<APP>_URL``.
|
||||||
|
|
||||||
|
Ce module ne lit l'environnement qu'à l'appel (jamais à l'import) :
|
||||||
|
les tests et un changement de ``.env`` suivi d'un redémarrage
|
||||||
|
prennent effet sans état figé.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# Défauts officiels (production future) — jamais de nom en ``-dev`` ici.
|
||||||
|
DEFAUTS: dict[str, str] = {
|
||||||
|
"agent": "https://agent.dracodev.net",
|
||||||
|
"obsigate": "https://og.dracodev.net",
|
||||||
|
"flowdeck": "https://flowdeck.dracodev.net",
|
||||||
|
"tiktak": "https://tiktak.dracodev.net",
|
||||||
|
"kanbante": "https://kanbante.dracodev.net",
|
||||||
|
"gitea": "https://git.dracodev.net",
|
||||||
|
}
|
||||||
|
|
||||||
|
_VARIABLES: dict[str, str] = {
|
||||||
|
"agent": "DRACO_AGENT_URL",
|
||||||
|
"obsigate": "DRACO_OBSIGATE_URL",
|
||||||
|
"flowdeck": "DRACO_FLOWDECK_URL",
|
||||||
|
"tiktak": "DRACO_TIKTAK_URL",
|
||||||
|
"kanbante": "DRACO_KANBANTE_URL",
|
||||||
|
"gitea": "DRACO_GITEA_URL",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Slugs d'hôte des applications sœurs pour la dérivation
|
||||||
|
# d'environnement (BUG-120) : premier label de ``<slug>.dracodev.net``
|
||||||
|
# (et de ``<slug>-dev.dracodev.net`` en dev). Gitea est **exclue** :
|
||||||
|
# son URL ne se dérive jamais.
|
||||||
|
_SLUGS_HOTE: dict[str, str] = {
|
||||||
|
"obsigate": "og",
|
||||||
|
"flowdeck": "flowdeck",
|
||||||
|
"tiktak": "tiktak",
|
||||||
|
"kanbante": "kanbante",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Premier label de l'hôte de l'agent : ``agent`` suivi d'un suffixe
|
||||||
|
# d'environnement éventuel (``-dev`` en dev, vide en prod).
|
||||||
|
_MOTIF_AGENT = re.compile(r"^agent(-[a-z0-9-]*)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def _nettoyer(valeur: str) -> str:
|
||||||
|
return valeur.strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def origine(url: str) -> str:
|
||||||
|
"""Origine ``schéma://hôte[:port]`` d'une URL (``""`` si illisible)."""
|
||||||
|
u = urlparse(url or "")
|
||||||
|
if u.scheme and u.netloc:
|
||||||
|
return f"{u.scheme}://{u.netloc}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def agent_url() -> str:
|
||||||
|
"""Base publique du service d'agents.
|
||||||
|
|
||||||
|
Priorité : ``DRACO_AGENT_URL``, puis l'origine de
|
||||||
|
``AGENT_EVENEMENTS_URL`` (déjà la configuration réelle du poste),
|
||||||
|
puis le défaut officiel.
|
||||||
|
"""
|
||||||
|
explicite = _nettoyer(os.environ.get(_VARIABLES["agent"], ""))
|
||||||
|
if explicite:
|
||||||
|
return explicite
|
||||||
|
evenements = _nettoyer(os.environ.get("AGENT_EVENEMENTS_URL", ""))
|
||||||
|
if evenements:
|
||||||
|
derivee = origine(evenements)
|
||||||
|
if derivee:
|
||||||
|
return derivee
|
||||||
|
return DEFAUTS["agent"]
|
||||||
|
|
||||||
|
|
||||||
|
def agent_origine() -> str:
|
||||||
|
"""Origine du service d'agents, pour la CSP (``connect-src``/``frame-src``)."""
|
||||||
|
return origine(agent_url()) or DEFAUTS["agent"]
|
||||||
|
|
||||||
|
|
||||||
|
def _suffixe_environnement() -> str | None:
|
||||||
|
"""Suffixe d'environnement déduit de l'hôte de l'agent (BUG-120).
|
||||||
|
|
||||||
|
Lit l'hôte de :func:`agent_url` — l'origine de l'agent telle
|
||||||
|
qu'ObsiGate la connaît déjà (``DRACO_AGENT_URL``, à défaut
|
||||||
|
``AGENT_EVENEMENTS_URL``). Si cet hôte est exactement de la forme
|
||||||
|
``agent<SUFFIXE>.dracodev.net`` sans port, retourne ``SUFFIXE``
|
||||||
|
(``-dev`` en dev, ``""`` en prod). Sinon (autre domaine, LAN, IP,
|
||||||
|
port exotique) retourne ``None`` : aucune dérivation, on ne
|
||||||
|
fabrique jamais un lien deviné.
|
||||||
|
"""
|
||||||
|
u = urlparse(agent_url())
|
||||||
|
hote = (u.hostname or "").lower()
|
||||||
|
labels = hote.split(".")
|
||||||
|
if len(labels) != 3 or labels[1:] != ["dracodev", "net"]:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if u.port is not None:
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
motif = _MOTIF_AGENT.match(labels[0])
|
||||||
|
if motif is None:
|
||||||
|
return None
|
||||||
|
return motif.group(1) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def app_url(app: str) -> str:
|
||||||
|
"""Base publique d'une application de l'écosystème (sans ``/`` final).
|
||||||
|
|
||||||
|
Résolution d'une URL sœur (BUG-120) : ``DRACO_<APP>_URL``
|
||||||
|
explicite d'abord ; à défaut, dérivation d'environnement depuis
|
||||||
|
l'hôte de l'agent (:func:`_suffixe_environnement`) ; à défaut,
|
||||||
|
le défaut officiel. L'URL Gitea ne se dérive jamais.
|
||||||
|
"""
|
||||||
|
if app == "agent":
|
||||||
|
return agent_url()
|
||||||
|
variable = _VARIABLES[app]
|
||||||
|
explicite = _nettoyer(os.environ.get(variable, ""))
|
||||||
|
if explicite:
|
||||||
|
return explicite
|
||||||
|
if app in _SLUGS_HOTE:
|
||||||
|
suffixe = _suffixe_environnement()
|
||||||
|
if suffixe is not None:
|
||||||
|
return f"https://{_SLUGS_HOTE[app]}{suffixe}.dracodev.net"
|
||||||
|
return DEFAUTS[app]
|
||||||
|
|
||||||
|
|
||||||
|
def app_hote(app: str) -> str:
|
||||||
|
"""Nom d'hôte (minuscules) de l'URL publique configurée de *app*."""
|
||||||
|
return (urlparse(app_url(app)).hostname or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def hotes_reconnus() -> dict[str, str]:
|
||||||
|
"""``{hôte: app}`` pour le parseur de liens profonds.
|
||||||
|
|
||||||
|
Contient **toujours** les hôtes officiels (un lien collé depuis la
|
||||||
|
production reste reconnaissable en dev, et réciproquement) **plus**
|
||||||
|
l'hôte résolu de chaque application (les noms en ``-dev`` du poste,
|
||||||
|
déclarés dans son ``.env`` ou dérivés de l'hôte de l'agent).
|
||||||
|
"""
|
||||||
|
hotes: dict[str, str] = {}
|
||||||
|
for app, defaut in DEFAUTS.items():
|
||||||
|
hote = (urlparse(defaut).hostname or "").lower()
|
||||||
|
if hote:
|
||||||
|
hotes[hote] = app
|
||||||
|
for app in DEFAUTS:
|
||||||
|
hote = app_hote(app)
|
||||||
|
if hote:
|
||||||
|
hotes[hote] = app
|
||||||
|
return hotes
|
||||||
|
|
||||||
|
|
||||||
|
def urls_publiques() -> dict[str, str]:
|
||||||
|
"""Carte des URL publiques injectée à l'hôte web (``window.DRACO_URLS``).
|
||||||
|
|
||||||
|
C'est le contrat d'injection de dracodev-commons : l'application
|
||||||
|
hôte (ici ObsiGate, qui sert ``index.html``) fournit les URL aux
|
||||||
|
assets partagés (panneau Spark, barre, liens) — les assets ne
|
||||||
|
portent que les défauts officiels en repli.
|
||||||
|
"""
|
||||||
|
return {app: app_url(app) for app in DEFAUTS}
|
||||||
+2
-2
@@ -359,7 +359,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
|||||||
|
|
||||||
rendered = _render_body(body, path.parent, vault_path, path)
|
rendered = _render_body(body, path.parent, vault_path, path)
|
||||||
|
|
||||||
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
|
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.UTC).timestamp())}"
|
||||||
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
|
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
|
||||||
@@ -383,7 +383,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
|||||||
<dc:title>{html_mod.escape(title)}</dc:title>
|
<dc:title>{html_mod.escape(title)}</dc:title>
|
||||||
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
||||||
<dc:language>fr</dc:language>
|
<dc:language>fr</dc:language>
|
||||||
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
<meta property="dcterms:modified">{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
||||||
</metadata>
|
</metadata>
|
||||||
<manifest>
|
<manifest>
|
||||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||||
|
|||||||
+76
-10
@@ -24,7 +24,7 @@ import uuid
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.external_refs")
|
logger = logging.getLogger("obsigate.external_refs")
|
||||||
|
|
||||||
@@ -64,26 +64,87 @@ def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
|
|||||||
|
|
||||||
# ------------------------------------------------------------ parseur d'URL
|
# ------------------------------------------------------------ parseur d'URL
|
||||||
|
|
||||||
|
def build_deep_link(target_app: str, target_type: str, target_id: str) -> str | None:
|
||||||
|
"""Construit l'URL profonde d'affichage depuis la **configuration**
|
||||||
|
courante (#223), ou ``None`` si la forme est inconnue.
|
||||||
|
|
||||||
|
Les références stockées portent une URL absolue figée au moment du
|
||||||
|
collage (voir ``add_ref``) : elle peut nommer l'autre environnement
|
||||||
|
(un lien collé en prod, lu en dev). L'affichage ne l'utilise donc
|
||||||
|
jamais telle quelle — il reconstruit l'URL depuis
|
||||||
|
``(target_app, target_type, target_id)`` et les bases publiques
|
||||||
|
configurées (``backend.draco_config``), au moment de la lecture.
|
||||||
|
Aucune donnée stockée n'est migrée ni réécrite.
|
||||||
|
"""
|
||||||
|
from backend.draco_config import app_url
|
||||||
|
|
||||||
|
if target_app == "tiktak":
|
||||||
|
if target_type == "task":
|
||||||
|
return f"{app_url('tiktak')}/?task={quote(target_id, safe='')}"
|
||||||
|
if target_type == "list":
|
||||||
|
return f"{app_url('tiktak')}/?list={quote(target_id, safe='')}"
|
||||||
|
return None
|
||||||
|
if target_app == "obsigate" and target_type == "note":
|
||||||
|
return f"{app_url('obsigate')}/#file={quote(target_id, safe=':')}"
|
||||||
|
if target_app == "flowdeck":
|
||||||
|
if target_type == "page":
|
||||||
|
return f"{app_url('flowdeck')}/pages/{quote(target_id, safe='')}"
|
||||||
|
if target_type == "project":
|
||||||
|
return f"{app_url('flowdeck')}/collections/{quote(target_id, safe='')}"
|
||||||
|
return None
|
||||||
|
if target_app == "kanbante" and target_type == "card":
|
||||||
|
m = re.fullmatch(r"([^/]+)/([^/#]+)#(\d+)", target_id or "")
|
||||||
|
if m:
|
||||||
|
owner, repo, num = m.groups()
|
||||||
|
return f"{app_url('gitea')}/{owner}/{repo}/issues/{num}"
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _avec_url_affichee(ref: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Copie de la référence dont ``target_url`` est reconstruite
|
||||||
|
depuis la config courante (#223) — le stockage n'est pas touché."""
|
||||||
|
construite = build_deep_link(
|
||||||
|
str(ref.get("target_app") or ""),
|
||||||
|
str(ref.get("target_type") or ""),
|
||||||
|
str(ref.get("target_id") or ""),
|
||||||
|
)
|
||||||
|
if construite is None:
|
||||||
|
return ref
|
||||||
|
copie = dict(ref)
|
||||||
|
copie["target_url"] = construite
|
||||||
|
return copie
|
||||||
|
|
||||||
|
|
||||||
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||||
"""Reconnaît une URL profonde des applications dracodev.
|
"""Reconnaît une URL profonde des applications dracodev.
|
||||||
|
|
||||||
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
|
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
|
||||||
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
|
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
|
||||||
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
|
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
|
||||||
|
|
||||||
|
Hôtes reconnus (#223) : les hôtes **officiels** et les hôtes
|
||||||
|
**configurés** de chaque application (``backend.draco_config``) —
|
||||||
|
un lien collé depuis l'environnement courant (noms en ``-dev``
|
||||||
|
déclarés dans son ``.env``) comme depuis l'autre environnement
|
||||||
|
est donc reconnu ; aucun nom d'environnement n'est figé ici.
|
||||||
"""
|
"""
|
||||||
|
from backend.draco_config import hotes_reconnus
|
||||||
|
|
||||||
try:
|
try:
|
||||||
u = urlparse((url or "").strip())
|
u = urlparse((url or "").strip())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
host = (u.hostname or "").lower()
|
host = (u.hostname or "").lower()
|
||||||
if host == "tiktak.dracodev.net":
|
app = hotes_reconnus().get(host)
|
||||||
|
if app == "tiktak":
|
||||||
q = parse_qs(u.query)
|
q = parse_qs(u.query)
|
||||||
if q.get("task"):
|
if q.get("task"):
|
||||||
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
|
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
|
||||||
if q.get("list"):
|
if q.get("list"):
|
||||||
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
|
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
|
||||||
return None
|
return None
|
||||||
if host == "og.dracodev.net":
|
if app == "obsigate":
|
||||||
frag = u.fragment or ""
|
frag = u.fragment or ""
|
||||||
if frag.startswith("file="):
|
if frag.startswith("file="):
|
||||||
cible = unquote(frag[5:])
|
cible = unquote(frag[5:])
|
||||||
@@ -91,7 +152,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
|||||||
nom = cible.rsplit("/", 1)[-1]
|
nom = cible.rsplit("/", 1)[-1]
|
||||||
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
|
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
|
||||||
return None
|
return None
|
||||||
if host == "flowdeck.dracodev.net":
|
if app == "flowdeck":
|
||||||
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
|
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
|
||||||
if m:
|
if m:
|
||||||
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
|
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
|
||||||
@@ -99,7 +160,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
|||||||
if m:
|
if m:
|
||||||
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
|
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
|
||||||
return None
|
return None
|
||||||
if host == "git.dracodev.net":
|
if app == "gitea":
|
||||||
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
|
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
|
||||||
if m:
|
if m:
|
||||||
owner, repo, num = m.groups()
|
owner, repo, num = m.groups()
|
||||||
@@ -111,20 +172,25 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
|||||||
# ------------------------------------------------------------ CRUD
|
# ------------------------------------------------------------ CRUD
|
||||||
|
|
||||||
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
|
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
|
||||||
"""Références posées sur une note, dans l'ordre de création."""
|
"""Références posées sur une note, dans l'ordre de création.
|
||||||
|
|
||||||
|
``target_url`` de chaque référence retournée est **reconstruite
|
||||||
|
depuis la config courante** (#223, ``_avec_url_affichee``) : la
|
||||||
|
valeur stockée, figée au collage, n'est jamais servie telle quelle.
|
||||||
|
"""
|
||||||
if not username:
|
if not username:
|
||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
r for r in _read_refs(username)
|
_avec_url_affichee(r) for r in _read_refs(username)
|
||||||
if r.get("owner_vault") == vault and r.get("owner_path") == path
|
if r.get("owner_vault") == vault and r.get("owner_path") == path
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
||||||
"""Une référence par son identifiant, ou ``None``."""
|
"""Une référence par son identifiant, ou ``None`` (URL reconstruite, #223)."""
|
||||||
for r in _read_refs(username):
|
for r in _read_refs(username):
|
||||||
if r.get("id") == ref_id:
|
if r.get("id") == ref_id:
|
||||||
return r
|
return _avec_url_affichee(r)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -169,4 +235,4 @@ def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
removed = next(r for r in refs if r.get("id") == ref_id)
|
removed = next(r for r in refs if r.get("id") == ref_id)
|
||||||
_write_refs(username, kept)
|
_write_refs(username, kept)
|
||||||
return removed
|
return _avec_url_affichee(removed)
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ def _export_footer(lang: str) -> str:
|
|||||||
template = loc.get("guide105.export_footer", "")
|
template = loc.get("guide105.export_footer", "")
|
||||||
if "%s" not in template and "{" not in template:
|
if "%s" not in template and "{" not in template:
|
||||||
template = "ObsiGate {version}"
|
template = "ObsiGate {version}"
|
||||||
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
|
today = datetime.datetime.now(tz=datetime.UTC).date().isoformat()
|
||||||
return _collapse(template).format(version=_app_version(), date=today)
|
return _collapse(template).format(version=_app_version(), date=today)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -581,7 +581,7 @@ def _scan_vault(
|
|||||||
try:
|
try:
|
||||||
relative = fpath.relative_to(vault_root)
|
relative = fpath.relative_to(vault_root)
|
||||||
stat = fpath.stat()
|
stat = fpath.stat()
|
||||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
|
||||||
|
|
||||||
# #86 differential scan: reuse the previous entry when neither
|
# #86 differential scan: reuse the previous entry when neither
|
||||||
# size nor mtime changed — skips the disk read + parse below.
|
# size nor mtime changed — skips the disk read + parse below.
|
||||||
@@ -866,7 +866,7 @@ async def build_index(progress_callback=None) -> None:
|
|||||||
|
|
||||||
# Record timestamp of full index rebuild
|
# Record timestamp of full index rebuild
|
||||||
global _last_full_index_ts
|
global _last_full_index_ts
|
||||||
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
|
_last_full_index_ts = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
# Build attachment index
|
# Build attachment index
|
||||||
from backend.attachment_indexer import build_attachment_index
|
from backend.attachment_indexer import build_attachment_index
|
||||||
@@ -1033,7 +1033,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
stat = fpath.stat()
|
stat = fpath.stat()
|
||||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
|
||||||
|
|
||||||
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
|
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
|
|||||||
+41
-3
@@ -186,15 +186,21 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
|
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
|
||||||
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
|
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
|
||||||
# `style=` et `el.style` omniprésents — chantier séparé).
|
# `style=` et `el.style` omniprésents — chantier séparé).
|
||||||
|
# #223 : l'origine du service d'agents n'est pas figée — elle
|
||||||
|
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
|
||||||
|
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
|
||||||
|
from backend.draco_config import agent_origine
|
||||||
|
|
||||||
|
agent = agent_origine()
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||||
"img-src 'self' data: blob:; "
|
"img-src 'self' data: blob:; "
|
||||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; "
|
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; "
|
||||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||||
"worker-src 'self' blob:; "
|
"worker-src 'self' blob:; "
|
||||||
"frame-src 'self' blob:; "
|
f"frame-src 'self' blob: {agent}; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
"form-action 'self'; "
|
"form-action 'self'; "
|
||||||
@@ -524,6 +530,7 @@ from backend.auth.middleware import (
|
|||||||
from backend.auth.router import router as auth_router
|
from backend.auth.router import router as auth_router
|
||||||
from backend.bookslm_routes import router as bookslm_router
|
from backend.bookslm_routes import router as bookslm_router
|
||||||
from backend.routers.backups import router as backups_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 _load_config
|
||||||
from backend.routers.config import router as config_router
|
from backend.routers.config import router as config_router
|
||||||
from backend.routers.conflicts import router as conflicts_router
|
from backend.routers.conflicts import router as conflicts_router
|
||||||
@@ -558,6 +565,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
|
|||||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||||
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||||
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
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(file_chat_router) # ROADMAP #169 — Chat par fichier
|
||||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
||||||
@@ -829,6 +837,36 @@ def _html_with_nonce(request: Request, name: str) -> str:
|
|||||||
request.state.csp_nonce,
|
request.state.csp_nonce,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_html(request: Request) -> str:
|
||||||
|
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
|
||||||
|
|
||||||
|
Contrat d'injection de dracodev-commons : l'hôte (ObsiGate, qui
|
||||||
|
sert la page) fournit les URL aux assets partagés — panneau Spark,
|
||||||
|
barre d'apps, liens inter-apps lisent ``window.DRACO_URLS`` et ne
|
||||||
|
portent que les défauts officiels en repli. Les valeurs viennent
|
||||||
|
de :mod:`backend.draco_config` (environnement ; défauts officiels,
|
||||||
|
noms en ``-dev`` déclarés par le seul ``.env`` du poste). Le script
|
||||||
|
injecté passe par le même mécanisme de nonce CSP que les autres
|
||||||
|
scripts inline de la page.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
from backend.csp import inject_csp_nonce
|
||||||
|
from backend.draco_config import urls_publiques
|
||||||
|
|
||||||
|
html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
|
||||||
|
script = (
|
||||||
|
"<script>window.DRACO_URLS = "
|
||||||
|
+ _json.dumps(urls_publiques()).replace("<", "\\u003c")
|
||||||
|
+ ";</script>"
|
||||||
|
)
|
||||||
|
if "</head>" in html:
|
||||||
|
html = html.replace("</head>", script + "</head>", 1)
|
||||||
|
else: # repli défensif : tout en tête de document
|
||||||
|
html = script + html
|
||||||
|
return inject_csp_nonce(html, request.state.csp_nonce)
|
||||||
|
|
||||||
if FRONTEND_DIR.exists():
|
if FRONTEND_DIR.exists():
|
||||||
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
|
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
|
||||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||||
@@ -907,5 +945,5 @@ if FRONTEND_DIR.exists():
|
|||||||
"""Serve the SPA index.html for all non-API routes."""
|
"""Serve the SPA index.html for all non-API routes."""
|
||||||
index_file = FRONTEND_DIR / "index.html"
|
index_file = FRONTEND_DIR / "index.html"
|
||||||
if index_file.exists():
|
if index_file.exists():
|
||||||
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
|
return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"})
|
||||||
raise HTTPException(status_code=404, detail="Frontend not found")
|
raise HTTPException(status_code=404, detail="Frontend not found")
|
||||||
|
|||||||
+4
-4
@@ -25,7 +25,7 @@ import os
|
|||||||
import smtplib
|
import smtplib
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -182,7 +182,7 @@ def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict
|
|||||||
"type": channel_type,
|
"type": channel_type,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"config": normalized,
|
"config": normalized,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"last_sent_at": None,
|
"last_sent_at": None,
|
||||||
"last_error": None,
|
"last_error": None,
|
||||||
}
|
}
|
||||||
@@ -287,7 +287,7 @@ def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
|
|||||||
"event": trigger,
|
"event": trigger,
|
||||||
"title": title,
|
"title": title,
|
||||||
"message": message,
|
"message": message,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"source": "obsigate-notify",
|
"source": "obsigate-notify",
|
||||||
},
|
},
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
@@ -345,6 +345,6 @@ def _mark_sent(channel_id: str, error: str | None) -> None:
|
|||||||
channels = _read_channels()
|
channels = _read_channels()
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
if channel.get("id") == channel_id:
|
if channel.get("id") == channel_id:
|
||||||
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
|
channel["last_sent_at"] = datetime.now(UTC).isoformat()
|
||||||
channel["last_error"] = error
|
channel["last_error"] = error
|
||||||
_write_channels(channels)
|
_write_channels(channels)
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ async def subscribe_push(
|
|||||||
"keys": request.subscription.keys,
|
"keys": request.subscription.keys,
|
||||||
"vault": request.vault,
|
"vault": request.vault,
|
||||||
"username": username,
|
"username": username,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat()
|
"created_at": datetime.now(UTC).isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
_push_subscriptions.append(subscription)
|
_push_subscriptions.append(subscription)
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ sortedcontainers>=2.4.0
|
|||||||
snowballstemmer>=2.2.0
|
snowballstemmer>=2.2.0
|
||||||
weasyprint>=70.0
|
weasyprint>=70.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
|
# Plancher de sécurité (BUG-093, relevé par #224) : 6.16.0 est vulnérable à
|
||||||
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
|
# deux DoS de ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm,
|
||||||
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
|
# fix 6.16.1), atteignables via backend/pdf_reader.py (PDF fournis par
|
||||||
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
|
# l'utilisateur). #224 : la version préinstallée dans la toolcache du
|
||||||
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
|
# runner (6.19.0) est frappée par 11 CVE supplémentaires (dont
|
||||||
# et n'est jamais mis à niveau.
|
# CVE-2026-113786, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580),
|
||||||
pypdf>=6.16.1
|
# corrigées au plus tard en 6.21.2 — le plancher doit être >= 6.21.2 :
|
||||||
|
# en dessous, pip répond « already satisfied » pour la toolcache et
|
||||||
|
# n'aligne jamais (même piège que BUG-093).
|
||||||
|
pypdf>=6.21.2
|
||||||
pyotp>=2.10.0
|
pyotp>=2.10.0
|
||||||
segno>=1.5.0
|
segno>=1.5.0
|
||||||
webauthn==2.6.0
|
webauthn==2.6.0
|
||||||
@@ -55,3 +58,20 @@ multidict>=6.9.1
|
|||||||
# transitive (httpx/requests) — sans plancher explicite, pip-audit
|
# transitive (httpx/requests) — sans plancher explicite, pip-audit
|
||||||
# signale la version héritée de l'environnement.
|
# signale la version héritée de l'environnement.
|
||||||
certifi>=2026.10.19
|
certifi>=2026.10.19
|
||||||
|
# Planchers de sécurité (#224) : paquets transitifs ou préinstallés dans
|
||||||
|
# la toolcache du runner, audités par pip-audit (CI, bloquant) mais jamais
|
||||||
|
# alignés sans plancher explicite — « already satisfied » (même piège que
|
||||||
|
# BUG-093). Versions relevées par le job security du run sur 2.78.0 :
|
||||||
|
# brotli 1.2.0 (transitive fonttools[woff] via weasyprint) — PYSEC-2026-4322
|
||||||
|
# et GHSA-9v8h-qqgm-4m35, corrigées en 1.2.1.
|
||||||
|
brotli>=1.2.1
|
||||||
|
# filelock 3.32.3 (transitive de CacheControl, outil pip-audit, et
|
||||||
|
# préinstallée dans la toolcache) — CVE-2026-112940, CVE-2026-112913 et
|
||||||
|
# CVE-2026-112907, corrigées en 4.2.1.
|
||||||
|
filelock>=4.2.1
|
||||||
|
# opentelemetry-api 1.37.0 (préinstallée dans la toolcache du runner,
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Adaptations strictement équivalentes :
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ async def api_backups_list(
|
|||||||
"file": rel_file,
|
"file": rel_file,
|
||||||
"backup_file": fpath.name,
|
"backup_file": fpath.name,
|
||||||
"timestamp": ts,
|
"timestamp": ts,
|
||||||
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
|
"datetime": datetime.fromtimestamp(ts, tz=UTC).isoformat(),
|
||||||
"size": fsize,
|
"size": fsize,
|
||||||
"full_path": str(fpath),
|
"full_path": str(fpath),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -15,6 +15,7 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from backend import agent_journal
|
||||||
from backend.audit import log_file_delete, log_file_save
|
from backend.audit import log_file_delete, log_file_save
|
||||||
from backend.auth.middleware import check_vault_access, require_auth
|
from backend.auth.middleware import check_vault_access, require_auth
|
||||||
from backend.history import (
|
from backend.history import (
|
||||||
@@ -133,6 +134,9 @@ async def api_file_save(
|
|||||||
client_ip = current_user.get("_request_ip", "unknown")
|
client_ip = current_user.get("_request_ip", "unknown")
|
||||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
|
|
||||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||||
|
|
||||||
|
|
||||||
@@ -201,6 +205,7 @@ def api_file_xlsx_save(
|
|||||||
sum(len(str(v)) for v in cells.values()),
|
sum(len(str(v)) for v in cells.values()),
|
||||||
current_user.get("_request_ip", "unknown"),
|
current_user.get("_request_ip", "unknown"),
|
||||||
)
|
)
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
return {
|
return {
|
||||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||||
"size": result["size"], "revision": result.get("revision"),
|
"size": result["size"], "revision": result.get("revision"),
|
||||||
@@ -250,6 +255,7 @@ def api_file_csv_save(
|
|||||||
sum(len(str(v)) for v in cells.values()),
|
sum(len(str(v)) for v in cells.values()),
|
||||||
current_user.get("_request_ip", "unknown"),
|
current_user.get("_request_ip", "unknown"),
|
||||||
)
|
)
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
return {
|
return {
|
||||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||||
"size": result["size"], "revision": result.get("revision"),
|
"size": result["size"], "revision": result.get("revision"),
|
||||||
@@ -313,6 +319,7 @@ def api_file_xlsx_structure(
|
|||||||
len(actions),
|
len(actions),
|
||||||
current_user.get("_request_ip", "unknown"),
|
current_user.get("_request_ip", "unknown"),
|
||||||
)
|
)
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
return {
|
return {
|
||||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||||
@@ -381,6 +388,7 @@ def api_file_xlsx_style(
|
|||||||
len(ops),
|
len(ops),
|
||||||
current_user.get("_request_ip", "unknown"),
|
current_user.get("_request_ip", "unknown"),
|
||||||
)
|
)
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
return {
|
return {
|
||||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||||
@@ -405,6 +413,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
|||||||
|
|
||||||
result = service_delete_file(vault_name, path)
|
result = service_delete_file(vault_name, path)
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_SUPPRIME, vault_name, result["path"])
|
||||||
|
|
||||||
# Audit log
|
# Audit log
|
||||||
client_ip = current_user.get("_request_ip", "unknown")
|
client_ip = current_user.get("_request_ip", "unknown")
|
||||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||||
@@ -577,6 +588,9 @@ async def api_file_create(
|
|||||||
|
|
||||||
result = service_create_file(vault_name, body.path, body.content)
|
result = service_create_file(vault_name, body.path, body.content)
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_CREE, vault_name, result["path"])
|
||||||
|
|
||||||
# Update index
|
# Update index
|
||||||
await update_single_file(vault_name, result["path"])
|
await update_single_file(vault_name, result["path"])
|
||||||
|
|
||||||
@@ -686,6 +700,9 @@ async def api_file_rename(
|
|||||||
old_path_str = result["old_path"]
|
old_path_str = result["old_path"]
|
||||||
new_path_str = result["new_path"]
|
new_path_str = result["new_path"]
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — la référence pointe sur le nouveau chemin.
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
|
||||||
|
|
||||||
# Update index
|
# Update index
|
||||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
@@ -731,6 +748,11 @@ async def api_file_move(
|
|||||||
new_path_str = result["new_path"]
|
new_path_str = result["new_path"]
|
||||||
item_type = result["item_type"]
|
item_type = result["item_type"]
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — fichiers seulement (le catalogue
|
||||||
|
# est note.*) ; les déplacements de dossiers ne sont pas émis.
|
||||||
|
if item_type == "file":
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
|
||||||
|
|
||||||
# Update index
|
# Update index
|
||||||
if item_type == "directory":
|
if item_type == "directory":
|
||||||
from backend.indexer import reload_single_vault
|
from backend.indexer import reload_single_vault
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async def api_events(current_user=Depends(require_auth)):
|
|||||||
try:
|
try:
|
||||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||||
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
# Keepalive comment
|
# Keepalive comment
|
||||||
yield ": keepalive\n\n"
|
yield ": keepalive\n\n"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
+12
-12
@@ -26,7 +26,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
parsed = datetime.fromisoformat(at)
|
parsed = datetime.fromisoformat(at)
|
||||||
if parsed.tzinfo is None:
|
if parsed.tzinfo is None:
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
|
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
|
||||||
return {"kind": kind, "at": parsed.isoformat()}
|
return {"kind": kind, "at": parsed.isoformat()}
|
||||||
@@ -138,7 +138,7 @@ def create_task(
|
|||||||
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
|
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
|
||||||
validated_action = _validate_action(action)
|
validated_action = _validate_action(action)
|
||||||
validated_schedule = _validate_schedule(schedule)
|
validated_schedule = _validate_schedule(schedule)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(UTC)
|
||||||
with _lock:
|
with _lock:
|
||||||
tasks = _read_tasks()
|
tasks = _read_tasks()
|
||||||
task = {
|
task = {
|
||||||
@@ -200,9 +200,9 @@ def delete_task(task_id: str) -> bool:
|
|||||||
|
|
||||||
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
|
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
|
||||||
"""Compute the next due datetime for *task*."""
|
"""Compute the next due datetime for *task*."""
|
||||||
now = now or datetime.now(timezone.utc)
|
now = now or datetime.now(UTC)
|
||||||
if now.tzinfo is None:
|
if now.tzinfo is None:
|
||||||
now = now.replace(tzinfo=timezone.utc)
|
now = now.replace(tzinfo=UTC)
|
||||||
schedule = task.get("schedule", {})
|
schedule = task.get("schedule", {})
|
||||||
kind = schedule.get("kind")
|
kind = schedule.get("kind")
|
||||||
last_run_at = task.get("last_run_at")
|
last_run_at = task.get("last_run_at")
|
||||||
@@ -211,7 +211,7 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
|
|||||||
try:
|
try:
|
||||||
last = datetime.fromisoformat(str(last_run_at))
|
last = datetime.fromisoformat(str(last_run_at))
|
||||||
if last.tzinfo is None:
|
if last.tzinfo is None:
|
||||||
last = last.replace(tzinfo=timezone.utc)
|
last = last.replace(tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
last = None
|
last = None
|
||||||
if kind == "interval_hours":
|
if kind == "interval_hours":
|
||||||
@@ -233,11 +233,11 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
|
|||||||
try:
|
try:
|
||||||
at = datetime.fromisoformat(str(schedule.get("at")))
|
at = datetime.fromisoformat(str(schedule.get("at")))
|
||||||
if at.tzinfo is None:
|
if at.tzinfo is None:
|
||||||
at = at.replace(tzinfo=timezone.utc)
|
at = at.replace(tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return now
|
return now
|
||||||
if task.get("last_run_at"):
|
if task.get("last_run_at"):
|
||||||
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
|
return datetime.max.replace(tzinfo=UTC) # déjà exécutée
|
||||||
return at
|
return at
|
||||||
return now + timedelta(hours=24)
|
return now + timedelta(hours=24)
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
|||||||
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
|
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
|
||||||
try:
|
try:
|
||||||
result = _execute_action(task)
|
result = _execute_action(task)
|
||||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
task["last_run_at"] = datetime.now(UTC).isoformat()
|
||||||
task["last_status"] = "ok"
|
task["last_status"] = "ok"
|
||||||
task["last_error"] = None
|
task["last_error"] = None
|
||||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||||
@@ -292,7 +292,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
|||||||
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
|
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
|
||||||
return {"task_id": task_id, "ok": True, "result": result}
|
return {"task_id": task_id, "ok": True, "result": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
task["last_run_at"] = datetime.now(UTC).isoformat()
|
||||||
task["last_status"] = "error"
|
task["last_status"] = "error"
|
||||||
task["last_error"] = str(e)
|
task["last_error"] = str(e)
|
||||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||||
@@ -315,7 +315,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
|||||||
|
|
||||||
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
||||||
"""Run every due task. Returns per-task outcomes (empty when idle)."""
|
"""Run every due task. Returns per-task outcomes (empty when idle)."""
|
||||||
now = now or datetime.now(timezone.utc)
|
now = now or datetime.now(UTC)
|
||||||
outcomes: list[dict[str, Any]] = []
|
outcomes: list[dict[str, Any]] = []
|
||||||
for task in _read_tasks():
|
for task in _read_tasks():
|
||||||
if not task.get("enabled", True):
|
if not task.get("enabled", True):
|
||||||
@@ -323,7 +323,7 @@ def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
|
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
|
||||||
if next_run.tzinfo is None:
|
if next_run.tzinfo is None:
|
||||||
next_run = next_run.replace(tzinfo=timezone.utc)
|
next_run = next_run.replace(tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
next_run = compute_next_run(task, now)
|
next_run = compute_next_run(task, now)
|
||||||
if next_run <= now:
|
if next_run <= now:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
dt = datetime.fromtimestamp(ts, tz=UTC).isoformat()
|
||||||
except (OSError, OverflowError, ValueError) as ts_err:
|
except (OSError, OverflowError, ValueError) as ts_err:
|
||||||
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
|
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
|
||||||
continue
|
continue
|
||||||
@@ -219,7 +219,7 @@ def diff_backup(
|
|||||||
status=500,
|
status=500,
|
||||||
details={"vault": vault_name, "path": path, "version": ts},
|
details={"vault": vault_name, "path": path, "version": ts},
|
||||||
) from e
|
) from e
|
||||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
dt = datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
return content, f"{path}@{dt}"
|
return content, f"{path}@{dt}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ def list_recent(
|
|||||||
for v_name, f in recent:
|
for v_name, f in recent:
|
||||||
iso_modified = f.get("modified", "")
|
iso_modified = f.get("modified", "")
|
||||||
try:
|
try:
|
||||||
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
|
mtime_dt = datetime.fromisoformat(iso_modified)
|
||||||
mtime_val = mtime_dt.timestamp()
|
mtime_val = mtime_dt.timestamp()
|
||||||
except Exception:
|
except Exception:
|
||||||
mtime_val = time.time()
|
mtime_val = time.time()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ def get_vault_root(vault_name: str) -> Path:
|
|||||||
|
|
||||||
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||||
"""Return the direct children of a vault directory (directories first)."""
|
"""Return the direct children of a vault directory (directories first)."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||||
from backend.vault_settings import get_vault_setting
|
from backend.vault_settings import get_vault_setting
|
||||||
@@ -98,7 +99,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified": stat.st_mtime,
|
"modified": stat.st_mtime,
|
||||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||||
"extension": entry.suffix.lower(),
|
"extension": entry.suffix.lower(),
|
||||||
})
|
})
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -156,7 +157,7 @@ def list_all_files(
|
|||||||
ServiceError: ``not_found`` (404) if the directory does not exist,
|
ServiceError: ``not_found`` (404) if the directory does not exist,
|
||||||
``permission_denied`` (403) if it cannot be read.
|
``permission_denied`` (403) if it cannot be read.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
from backend.indexer import IGNORED_DIRS
|
from backend.indexer import IGNORED_DIRS
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ def list_all_files(
|
|||||||
"vault": vault_name,
|
"vault": vault_name,
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified": stat.st_mtime,
|
"modified": stat.st_mtime,
|
||||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||||
"extension": ext.lstrip(".") if ext else "",
|
"extension": ext.lstrip(".") if ext else "",
|
||||||
"tags": _indexed_tags(vault_name, rel_path),
|
"tags": _indexed_tags(vault_name, rel_path),
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -11,7 +11,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.share")
|
logger = logging.getLogger("obsigate.share")
|
||||||
@@ -58,7 +58,7 @@ def create_share(
|
|||||||
|
|
||||||
expires_at = None
|
expires_at = None
|
||||||
if expires_in_hours:
|
if expires_in_hours:
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
expires_at = (datetime.now(UTC) + timedelta(hours=expires_in_hours)).isoformat()
|
||||||
|
|
||||||
share = {
|
share = {
|
||||||
"id": token,
|
"id": token,
|
||||||
@@ -66,7 +66,7 @@ def create_share(
|
|||||||
"vault": vault,
|
"vault": vault,
|
||||||
"path": path,
|
"path": path,
|
||||||
"created_by": created_by,
|
"created_by": created_by,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"access_count": 0,
|
"access_count": 0,
|
||||||
"last_accessed": None,
|
"last_accessed": None,
|
||||||
@@ -86,7 +86,7 @@ def get_share_by_token(token: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
if share.get("expires_at"):
|
if share.get("expires_at"):
|
||||||
expires = datetime.fromisoformat(share["expires_at"])
|
expires = datetime.fromisoformat(share["expires_at"])
|
||||||
if datetime.now(timezone.utc) > expires:
|
if datetime.now(UTC) > expires:
|
||||||
return None
|
return None
|
||||||
return share
|
return share
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def record_access(token: str):
|
|||||||
share = data["shares"].get(token)
|
share = data["shares"].get(token)
|
||||||
if share:
|
if share:
|
||||||
share["access_count"] = share.get("access_count", 0) + 1
|
share["access_count"] = share.get("access_count", 0) + 1
|
||||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
share["last_accessed"] = datetime.now(UTC).isoformat()
|
||||||
_write(data)
|
_write(data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Reuses the application audit log (``data/audit.log``, JSON lines) and adds an
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.audit import _write_entry
|
from backend.audit import _write_entry
|
||||||
@@ -41,7 +41,7 @@ def log_tool_call(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Append an ``ai_tool_call`` entry to the audit log."""
|
"""Append an ``ai_tool_call`` entry to the audit log."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "ai_tool_call",
|
"action": "ai_tool_call",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from backend import agent_journal
|
||||||
from backend.indexer import get_backlinks as _get_backlinks
|
from backend.indexer import get_backlinks as _get_backlinks
|
||||||
from backend.indexer import get_vault_names
|
from backend.indexer import get_vault_names
|
||||||
from backend.services.backups import diff_backup as _diff_backup
|
from backend.services.backups import diff_backup as _diff_backup
|
||||||
@@ -58,7 +59,7 @@ from backend.services.search import advanced_search_vaults, search_vaults
|
|||||||
from backend.services.search import list_tags as _list_tags
|
from backend.services.search import list_tags as _list_tags
|
||||||
from backend.services.search import search_paths as _search_paths
|
from backend.services.search import search_paths as _search_paths
|
||||||
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
|
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
|
||||||
from backend.tools.context import ToolContext, ToolRisk
|
from backend.tools.context import ToolContext, ToolMode, ToolRisk
|
||||||
from backend.tools.registry import tool
|
from backend.tools.registry import tool
|
||||||
from backend.tools.schemas import (
|
from backend.tools.schemas import (
|
||||||
AppendToFileInput,
|
AppendToFileInput,
|
||||||
@@ -360,6 +361,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
|||||||
# ── D. Mutations ───────────────────────────────────────────────────────────
|
# ── D. Mutations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _acteur_journal(ctx: ToolContext) -> str:
|
||||||
|
"""Acteur journal (#220) : ``agent:mcp`` quand l'appel vient du
|
||||||
|
serveur MCP d'ObsiGate, ``user`` pour l'assistant intégré."""
|
||||||
|
return agent_journal.ACTEUR_MCP if ctx.mode == ToolMode.MCP else agent_journal.ACTEUR_USER
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="create_file",
|
name="create_file",
|
||||||
description=(
|
description=(
|
||||||
@@ -374,7 +381,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
||||||
"""Create a vault file (fails if it already exists)."""
|
"""Create a vault file (fails if it already exists)."""
|
||||||
return _create_file(params.vault, params.path, params.content)
|
result = _create_file(params.vault, params.path, params.content)
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_CREE, params.vault, result.get("path", params.path),
|
||||||
|
actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -402,7 +414,12 @@ def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str
|
|||||||
)
|
)
|
||||||
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
||||||
"""Replace a vault file's content."""
|
"""Replace a vault file's content."""
|
||||||
return _edit_file(params.vault, params.path, params.content)
|
result = _edit_file(params.vault, params.path, params.content)
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_EDITE, params.vault, result.get("path", params.path),
|
||||||
|
actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -414,7 +431,12 @@ def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
||||||
"""Append content to a vault file."""
|
"""Append content to a vault file."""
|
||||||
return _append_to_file(params.vault, params.path, params.content)
|
result = _append_to_file(params.vault, params.path, params.content)
|
||||||
|
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_EDITE, params.vault, chemin, actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -426,7 +448,12 @@ def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any
|
|||||||
)
|
)
|
||||||
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
||||||
"""Rename a vault file."""
|
"""Rename a vault file."""
|
||||||
return _rename_file(params.vault, params.path, params.new_name)
|
result = _rename_file(params.vault, params.path, params.new_name)
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_DEPLACE, params.vault,
|
||||||
|
result.get("new_path", params.path), actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -450,7 +477,14 @@ def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str
|
|||||||
)
|
)
|
||||||
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
||||||
"""Move a vault file or directory."""
|
"""Move a vault file or directory."""
|
||||||
return _move_path(params.vault, params.source_path, params.destination_dir)
|
result = _move_path(params.vault, params.source_path, params.destination_dir)
|
||||||
|
# Journal (#220) : fichiers seulement — les dossiers sont hors catalogue.
|
||||||
|
if result.get("item_type") == "file":
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_DEPLACE, params.vault,
|
||||||
|
result.get("new_path", params.source_path), actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -497,7 +531,12 @@ def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str,
|
|||||||
)
|
)
|
||||||
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
||||||
"""Delete a vault file."""
|
"""Delete a vault file."""
|
||||||
return _delete_file(params.vault, params.path)
|
result = _delete_file(params.vault, params.path)
|
||||||
|
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_SUPPRIME, params.vault, chemin, actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|||||||
+1
-1
@@ -244,7 +244,7 @@ class VaultWatcher:
|
|||||||
key = event.get('dest') or event['src']
|
key = event.get('dest') or event['src']
|
||||||
pending[key] = event
|
pending[key] = event
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
if pending:
|
if pending:
|
||||||
events_to_process = list(pending.values())
|
events_to_process = list(pending.values())
|
||||||
pending.clear()
|
pending.clear()
|
||||||
|
|||||||
+4
-4
@@ -31,7 +31,7 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ def create_webhook(
|
|||||||
"url": url,
|
"url": url,
|
||||||
"events": [e for e in events if e in VALID_EVENTS],
|
"events": [e for e in events if e in VALID_EVENTS],
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"last_fired_at": None,
|
"last_fired_at": None,
|
||||||
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
|
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
|
||||||
"vault": _normalize_vault_filter(vault),
|
"vault": _normalize_vault_filter(vault),
|
||||||
@@ -344,7 +344,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"event": event_type,
|
"event": event_type,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
}
|
||||||
body = json.dumps(payload, default=str)
|
body = json.dumps(payload, default=str)
|
||||||
@@ -369,7 +369,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
|||||||
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
|
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
|
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
|
||||||
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
|
update_webhook(wh["id"], {"last_fired_at": datetime.now(UTC).isoformat()})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Webhook '{wh['name']}' error: {e}")
|
logger.warning(f"Webhook '{wh['name']}' error: {e}")
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.74.0"
|
version = "2.80.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.74.0"
|
version = "2.80.1"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.74.0",
|
"version": "2.80.1",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ services:
|
|||||||
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
|
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
|
||||||
- VAULT_5_NAME=Sessions
|
- VAULT_5_NAME=Sessions
|
||||||
- VAULT_5_PATH=/vaults/SessionsManager
|
- VAULT_5_PATH=/vaults/SessionsManager
|
||||||
- DIR_1_NAME=Bruno
|
|
||||||
- DIR_1_PATH=/vaults/bruno
|
|
||||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||||
- OBSIGATE_AUTH_ENABLED=true
|
- OBSIGATE_AUTH_ENABLED=true
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK |
|
| *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK |
|
||||||
| *BUG-116* | Éditeur Forge : après insertion d'un tableau (ex. 4 lignes), la textarea reste d'une seule ligne au lieu de s'agrandir | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (`insertAtCursor`, `wrapSelection`) | Insérer un tableau via le menu : le contenu contient bien les nouvelles lignes mais la hauteur de la textarea ne change pas | Les mutations programmatiques de `ta.value` ne déclenchent pas l'événement `input` — seul handler qui rappelait `autoHeight()`/`updateLineNumbers()` (les handlers clavier, eux, les appelaient à la main). Ajout des deux appels dans `insertAtCursor` et `wrapSelection`, les deux points de passage de toutes les insertions (tableau, code, mermaid, checklist, gras multi-ligne) | Vérifié en Playwright sur l'instance E2E (qi → Table, 4 lignes, sur doc d'une ligne) : hauteur 51 → 189 px, 8 lignes rendues, gutter aligné | ### TODOs techniques (améliorations / nouvelles tâches)
|
| *BUG-116* | Éditeur Forge : après insertion d'un tableau (ex. 4 lignes), la textarea reste d'une seule ligne au lieu de s'agrandir | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (`insertAtCursor`, `wrapSelection`) | Insérer un tableau via le menu : le contenu contient bien les nouvelles lignes mais la hauteur de la textarea ne change pas | Les mutations programmatiques de `ta.value` ne déclenchent pas l'événement `input` — seul handler qui rappelait `autoHeight()`/`updateLineNumbers()` (les handlers clavier, eux, les appelaient à la main). Ajout des deux appels dans `insertAtCursor` et `wrapSelection`, les deux points de passage de toutes les insertions (tableau, code, mermaid, checklist, gras multi-ligne) | Vérifié en Playwright sur l'instance E2E (qi → Table, 4 lignes, sur doc d'une ligne) : hauteur 51 → 189 px, 8 lignes rendues, gutter aligné | ### TODOs techniques (améliorations / nouvelles tâches)
|
||||||
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
|
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
|
||||||
|
| *BUG-119* | Panneau Spark : le « mode console » (surimpression iframe de la console du service d'agents) est bloqué par le navigateur (« This content is blocked ») | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Panneau Spark → « Console ⤢ » : l'iframe reste sur la page d'erreur du navigateur | `frame-src 'self' blob: https://agent.dracodev.net` dans la CSP servie ; test `tests/test_security_headers.py::test_csp_frame_src_allows_agent_console` | Cause : `frame-src` n'autorisait pas l'origine du service d'agents, pourtant déjà présente dans `connect-src` (le panneau texte fonctionnait). Aucune autre directive modifiée. Redéploiement Docker requis |
|
||||||
|
| *BUG-120* | Menu inter-apps : sur l'environnement dev, les URL sœurs servies (`window.DRACO_URLS` : ObsiGate, Flowdeck, TikTak, Kanbante) restent en noms **prod** alors que l'URL de l'agent est bien en `-dev` | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/draco_config.py` (`app_url`, `_suffixe_environnement`) | Sur og-dev : `window.DRACO_URLS` donne `agent: https://agent-dev.dracodev.net` mais `tiktak: https://tiktak.dracodev.net` (prod) — le `.env` dev ne déclare pas les quatre `DRACO_<APP>_URL` | `backend/draco_config.py` : résolution sœur en 3 temps — explicite, puis **dérivation d'environnement** depuis l'hôte de `agent_url()` (`agent<SUFFIXE>.dracodev.net` → `https://<slug><SUFFIXE>.dracodev.net`, slugs `og`/`flowdeck`/`tiktak`/`kanbante` ; hors motif → rien), puis défaut officiel ; Gitea exclue. Tests `tests/test_draco_config.py` (+9 : dérivation `-dev` via `AGENT_EVENEMENTS_URL` et via `DRACO_AGENT_URL`, priorité de l'explicite, 5 hôtes hors motif, suffixe vide, injection) | Suite de #223 : seule l'URL de l'agent avait une dérivation de repli (`AGENT_EVENEMENTS_URL`) ; les sœurs retombaient sur les défauts officiels. Correctif de code demandé par Bruno : l'environnement se déduit de l'agent déjà configuré, sans variable supplémentaire. Redéploiement Docker requis |
|
||||||
|
|
||||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
@@ -357,6 +359,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
|
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
|
||||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-23 | BUG-119 | Correction | `backend/main.py`, `tests/test_security_headers.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | **BUG-119 — le « mode console » du panneau Spark V3 (#222) était bloqué par le navigateur** (« This content is blocked ») : la console du service d'agents s'affiche dans une iframe `https://agent.dracodev.net`, mais la CSP servie par `SecurityHeadersMiddleware` limitait `frame-src` à `'self' blob:` — seul le panneau texte passait (`connect-src` autorisait déjà l'origine). Correctif strict : `frame-src 'self' blob: https://agent.dracodev.net`, toutes les autres directives inchangées à l'identique. Test de non-régression `test_csp_frame_src_allows_agent_console` (la CSP servie est analysée et la liste exacte des sources `frame-src` verrouillée). Vérifié : 100 passed (security_headers + csp_nonce + security_hardening + draco_barre + version), ruff propre sur les fichiers touchés (51 erreurs préexistantes ailleurs, inchangées), mypy 0 (120 fichiers) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-25 | BUG-120 (suite #223) | Correction | `backend/draco_config.py`, `tests/test_draco_config.py`, `.env.example`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/devprod-urls-config-223.md` | **BUG-120 — le menu inter-apps de la dev pointait la prod** : `window.DRACO_URLS` servait l'agent en `-dev` mais les quatre sœurs en noms officiels, le `.env` dev ne déclarant pas les `DRACO_<APP>_URL`. Correctif dans `draco_config.app_url()` : après l'explicite, **dérivation d'environnement** — hôte de `agent_url()` de la forme `agent<SUFFIXE>.dracodev.net` sans port → sœur en `<slug><SUFFIXE>.dracodev.net` ; hors motif (autre domaine, LAN, IP, port) aucune dérivation ; Gitea jamais dérivée. Vérifié : `test_draco_config.py` 16 passed, suite complète **1668 passed / 9 skipped**, ruff 0 et mypy 0 sur les fichiers touchés (51 erreurs ruff préexistantes ailleurs, inchangées) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+140
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.74.0 | **Dernière mise à jour :** 2026-10-22
|
> **Version :** 2.80.1 | **Dernière mise à jour :** 2026-10-27
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -30,6 +30,125 @@
|
|||||||
|
|
||||||
## 🔵 En cours
|
## 🔵 En cours
|
||||||
|
|
||||||
|
### 225. Fusion — panneau Spark : version du service d'agents visible en mode sidebar
|
||||||
|
|
||||||
|
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-26 | **Statut :** ✅ livré le 2026-10-26 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
|
||||||
|
- **Fiche :** [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md)
|
||||||
|
- **Description :** demande de Bruno (2026-10-26, carte fusion #64) : la
|
||||||
|
version « Service d'agents vX », déjà visible dans la console pleine
|
||||||
|
page, doit se lire aussi dans le **panneau natif des apps** (mode
|
||||||
|
sidebar). Le pied du panneau Spark affiche désormais cette ligne,
|
||||||
|
discrète et permanente, lue **en direct** dans la santé du service
|
||||||
|
(`GET /healthz` de l'origine injectée par l'hôte) à l'ouverture du
|
||||||
|
panneau, avec cache de session — jamais une valeur codée en dur ;
|
||||||
|
service injoignable = rien d'affiché. Même livraison : TikTak
|
||||||
|
0.61.3, Flowdeck 7.83.1, Kanbante 0.37.1.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Ligne de version dans le pied du panneau (canonique TikTak, propagée aux 4 apps)
|
||||||
|
- [x] Test `tests/test_draco_barre.py::test_panneau_version_service_affichee_225` + épreuve comportementale jsdom sur les 4 copies
|
||||||
|
- [x] Release publiée — CI verte à constater, redéploiement Docker (geste de Bruno)
|
||||||
|
|
||||||
|
### 224. Sécurité des dépendances — pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
|
||||||
|
|
||||||
|
- **Effort :** ~0,1 jour | **Impact :** 🔴 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
|
||||||
|
- **Fiche :** [features/securite-dependances-pip-audit-224.md](./features/securite-dependances-pip-audit-224.md)
|
||||||
|
- **Description :** le job `security` du run CI 2303 (2.78.0) échoue
|
||||||
|
sur `pip-audit` : 17 vulnérabilités dans 4 paquets hérités de
|
||||||
|
l'environnement du runner (toolcache « already satisfied », même
|
||||||
|
piège que BUG-093). Planchers explicites dans
|
||||||
|
`backend/requirements.txt` : pypdf relevé à `>=6.21.2`, nouveaux
|
||||||
|
planchers `brotli>=1.2.1`, `filelock>=4.2.1`,
|
||||||
|
`opentelemetry-api>=1.45.1` ; garde-fou `TestDependencySecurityFloors`
|
||||||
|
étendu. Aucun code applicatif, aucun nouvel ignore pip-audit.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Planchers de sécurité dans `backend/requirements.txt` + commentaires
|
||||||
|
- [x] Garde-fou `tests/test_ci_workflow.py::TestDependencySecurityFloors` étendu (pypdf 6.21.2, brotli, filelock, opentelemetry-api)
|
||||||
|
- [x] `pip-audit` local : 0 vulnérabilité non ignorée (mêmes ignores que la CI)
|
||||||
|
- [x] Release publiée — CI verte à constater, redéploiement Docker (geste de Bruno)
|
||||||
|
|
||||||
|
### 223. Dev/prod sans noms publics en dur — URLs de l'écosystème par configuration
|
||||||
|
|
||||||
|
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis, et en dev déclarer les noms en `-dev` dans le `.env` du poste)
|
||||||
|
- **Fiche :** [features/devprod-urls-config-223.md](./features/devprod-urls-config-223.md)
|
||||||
|
- **Description :** aucun nom public de l'écosystème dracodev ne doit être
|
||||||
|
figé dans le code vivant : les URL publiques (service d'agents, autres
|
||||||
|
apps) viennent de la configuration (défauts = noms **officiels** sans
|
||||||
|
suffixe ; l'environnement dev déclare ses noms en `-dev` dans son
|
||||||
|
`.env`). CSP dérivée de l'origine de l'agent configurée, panneau Spark
|
||||||
|
et liens inter-apps alimentés par l'hôte (injection `window.DRACO_URLS`
|
||||||
|
au service de `index.html`), références croisées reconstruites depuis
|
||||||
|
la config à l'affichage (aucune donnée migrée).
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Module `backend/draco_config.py` + `.env.example`
|
||||||
|
- [x] CSP `connect-src`/`frame-src` dérivée de la config (tests via config)
|
||||||
|
- [x] `external_refs` : hôtes reconnus + URL d'affichage construites par config
|
||||||
|
- [x] Frontend : panneau Spark, liens TikTak, onglets des apps via `DRACO_URLS`
|
||||||
|
- [x] WebAuthn : confirmation rp_id/origines dérivés de la requête / `OBSIGATE_WEBAUTHN_*`
|
||||||
|
|
||||||
|
### 222. Fusion — panneau Spark V3 : la console en surimpression pleine page
|
||||||
|
|
||||||
|
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-23 | **Statut :** ✅ livré le 2026-10-23 (2.77.0, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-panneau-console-surimpression-222.md](./features/fusion-panneau-console-surimpression-222.md)
|
||||||
|
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
|
||||||
|
octet-identique dans les 4 apps) passe en V3 (demande de Bruno,
|
||||||
|
2026-10-22) : le bouton « Console ⤢ » n'ouvre plus la console du
|
||||||
|
service d'agents en nouvel onglet (#221) mais en **surimpression
|
||||||
|
pleine page dans la même page** — montée à la largeur du panneau,
|
||||||
|
étendue à toute la fenêtre par transition, iframe gardant le lien
|
||||||
|
profond (`?disc=` / `?app=`), et deux sorties dans sa barre :
|
||||||
|
« Mode sidebar » (rétraction vers le panneau) et « Fermer l'agent ».
|
||||||
|
Styles injectés par le panneau ; côté service, la console autorise
|
||||||
|
l'encadrement par les 4 apps (0.20.0). Même livraison : TikTak
|
||||||
|
0.60.0, Flowdeck 7.81.0, Kanbante 0.29.0.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Surimpression + barre « Mode sidebar » / « Fermer l'agent »
|
||||||
|
dans le panneau canonique (TikTak), recopié octet-identique
|
||||||
|
dans les 4 apps
|
||||||
|
- [x] Test `tests/test_draco_barre.py::test_panneau_console_surimpression_222`
|
||||||
|
(et révision du test #221 : le nouvel onglet disparaît)
|
||||||
|
- [x] Release v2.77.0 publiée — CI à constater, redéploiement Docker (geste de Bruno)
|
||||||
|
|
||||||
|
### 221. Fusion — panneau Spark : bouton « Console ↗ » vers la console du service
|
||||||
|
|
||||||
|
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.1, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-panneau-console-221.md](./features/fusion-panneau-console-221.md)
|
||||||
|
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
|
||||||
|
octet-identique dans les 4 apps) gagne un bouton « Console ↗ » dans sa barre
|
||||||
|
des discussions (demande de Bruno, 2026-10-22) : il ouvre la console web du
|
||||||
|
service d'agents (https://agent.dracodev.net/) dans un nouvel onglet, sur la
|
||||||
|
discussion actuellement affichée (`?disc=<id>`, URL construite au clic) et
|
||||||
|
avec l'app d'origine (`?app=obsigate`, déduite du sous-domaine `og`). La
|
||||||
|
console consomme déjà ces deux paramètres (lien profond de discussion +
|
||||||
|
puce « Ouvert depuis »). Même livraison : TikTak 0.59.1, Flowdeck 7.80.1,
|
||||||
|
Kanbante 0.28.1.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Bouton + `ouvrirConsole()` / `appHote()` dans le panneau canonique
|
||||||
|
(TikTak), recopié octet-identique dans les 4 apps
|
||||||
|
- [x] Test `tests/test_draco_barre.py::test_panneau_bouton_console_221`
|
||||||
|
- [x] Release v2.76.1 publiée — CI à constater, redéploiement Docker (geste de Bruno)
|
||||||
|
|
||||||
|
### 220. Fusion — émission d'événements vers le journal du service d'agents (Lot 2, vague 5)
|
||||||
|
|
||||||
|
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.0, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-journal-evenements-220.md](./features/fusion-journal-evenements-220.md)
|
||||||
|
- **Description :** ObsiGate émet ses mutations de notes **explicites**
|
||||||
|
vers le journal d'activité du service d'agents (contrat ACC-0.2,
|
||||||
|
carte fusion-trois-apps #18) : `obsigate.note.created / edited /
|
||||||
|
moved / deleted`, signées HMAC, en arrière-plan, inactives sans la
|
||||||
|
clé `AGENT_EVENEMENTS_CLE`. Points d'accroche : les endpoints
|
||||||
|
d'écriture de l'API web (acteur `user`) et la couche outils /
|
||||||
|
serveur MCP (acteur `agent:mcp`). **Garde-fou : jamais le
|
||||||
|
watcher** — seules les opérations explicites via l'API émettent.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Module `backend/agent_journal.py` (enveloppe ACC-0.2,
|
||||||
|
signature HMAC-SHA256, envoi « au mieux »)
|
||||||
|
- [x] Branchements `backend/routers/files_write.py` (créer,
|
||||||
|
sauvegarder, renommer, déplacer, supprimer)
|
||||||
|
- [x] Branchements `backend/tools/service.py` (create/edit/
|
||||||
|
append/rename/move/delete, acteur selon le mode)
|
||||||
|
- [x] Tests `tests/test_agent_journal.py` — dont la preuve du
|
||||||
|
garde-fou watcher (écriture disque + indexation = silence)
|
||||||
|
|
||||||
### 218. Fusion — panneau Spark intégré (agent commun)
|
### 218. Fusion — panneau Spark intégré (agent commun)
|
||||||
|
|
||||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.74.0, redéploiement Docker en attente)
|
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.74.0, redéploiement Docker en attente)
|
||||||
@@ -48,6 +167,23 @@
|
|||||||
(`backend/main.py`, seul hôte ajouté)
|
(`backend/main.py`, seul hôte ajouté)
|
||||||
- [x] Tests `test_draco_barre.py` (intégration + CSP verrouillées)
|
- [x] Tests `test_draco_barre.py` (intégration + CSP verrouillées)
|
||||||
|
|
||||||
|
### 219. Fusion — panneau Spark v2 (Lot 1)
|
||||||
|
|
||||||
|
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.75.0, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-panneau-spark-v2-219.md](./features/fusion-panneau-spark-v2-219.md)
|
||||||
|
- **Description :** le panneau Spark (#218) passe en v2, à l'identique
|
||||||
|
dans les quatre applications (carte fusion-trois-apps #17) :
|
||||||
|
réponses de l'agent en **Markdown rendu** (HTML produit par le
|
||||||
|
service d'agents 0.10.0, sanitizé côté serveur), **largeur
|
||||||
|
réglable** persistée, **thème clair/sombre aligné** sur
|
||||||
|
l'application hôte, **discussions façon Muse** (principale
|
||||||
|
perpétuelle + parallèles créées/archivées/supprimées). Côté
|
||||||
|
ObsiGate : assets vendangés uniquement, aucun code propre touché.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||||
|
remplacés par la v2 partagée du programme
|
||||||
|
- [x] Fiche de conception `features/fusion-panneau-spark-v2-219.md`
|
||||||
|
|
||||||
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
|
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
|
||||||
|
|
||||||
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
|
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
|
||||||
@@ -1030,6 +1166,9 @@
|
|||||||
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||||
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||||
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||||
|
| BUG-119 | Panneau Spark — « mode console » bloqué par la CSP (`frame-src` sans l'origine du service d'agents) | 2.77.1 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
|
||||||
|
| BUG-120 | Menu inter-apps — les URL sœurs (`window.DRACO_URLS`) dérivent l'environnement de l'hôte de l'agent (`-dev` en dev), explicite prioritaire, Gitea inchangée (suite de #223) | 2.78.2 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
|
||||||
|
| 225 | Panneau Spark — version du service d'agents visible en mode sidebar (pied du panneau, lue en direct via `GET /healthz`) | 2.79.0 | [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# #223 — Dev/prod sans noms publics en dur : URLs de l'écosystème par configuration
|
||||||
|
|
||||||
|
> Ouvert le 2026-10-25 (chantier dev/prod de Bruno). Contexte : l'installation
|
||||||
|
> actuelle (`deepin.lab.home`) devient l'environnement **dev** (noms en `-dev`),
|
||||||
|
> la future installation (`jump.point.mint.home`) sera la **prod** (noms
|
||||||
|
> officiels sans suffixe), identique sauf noms et clés. Règle : aucun nom
|
||||||
|
> public de l'écosystème codé en dur dans le code vivant ; défauts = noms
|
||||||
|
> officiels ; le dev déclare ses `-dev` dans son seul `.env`.
|
||||||
|
|
||||||
|
## Configuration (nouveau module `backend/draco_config.py`)
|
||||||
|
|
||||||
|
Lecture à l'appel, jamais à l'import. Défauts officiels :
|
||||||
|
|
||||||
|
| Variable | Défaut officiel | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| `DRACO_AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents. À défaut : origine dérivée d'`AGENT_EVENEMENTS_URL`, puis défaut. |
|
||||||
|
| `DRACO_OBSIGATE_URL` | `https://og.dracodev.net` | Liens profonds ObsiGate |
|
||||||
|
| `DRACO_FLOWDECK_URL` | `https://flowdeck.dracodev.net` | Liens profonds Flowdeck |
|
||||||
|
| `DRACO_TIKTAK_URL` | `https://tiktak.dracodev.net` | Liens profonds TikTak |
|
||||||
|
| `DRACO_KANBANTE_URL` | `https://kanbante.dracodev.net` | Onglet Kanbante de la barre |
|
||||||
|
| `DRACO_GITEA_URL` | `https://git.dracodev.net` | Cartes Kanbante (= issues Gitea). Distincte d'`OBSIGATE_GITEA_URL` (source connectée de l'assistant). |
|
||||||
|
|
||||||
|
`AGENT_EVENEMENTS_URL` (existant, #220) reste la configuration première du
|
||||||
|
journal d'événements ; `agent_journal._url()` retombe sur
|
||||||
|
`DRACO_AGENT_URL + /api/v1/evenements` quand elle est absente.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- **CSP** (`backend/main.py`, `SecurityHeadersMiddleware`) : `connect-src` et
|
||||||
|
`frame-src` utilisent `draco_config.agent_origine()` — dérivée de la config
|
||||||
|
à chaque réponse, plus aucun littéral. Tests dans
|
||||||
|
`tests/test_security_headers.py` **via la config** (défaut officiel neutralisé,
|
||||||
|
dérivation depuis `AGENT_EVENEMENTS_URL`, priorité `DRACO_AGENT_URL`).
|
||||||
|
- **Références croisées** (`backend/external_refs.py`) :
|
||||||
|
- `parse_deep_link` reconnaît les hôtes officiels **et** configurés
|
||||||
|
(`draco_config.hotes_reconnus()`) — un lien collé depuis le dev comme
|
||||||
|
depuis la prod est reconnu.
|
||||||
|
- `build_deep_link(app, type, id)` construit l'URL d'affichage depuis la
|
||||||
|
config ; `list_refs` / `get_ref` / `delete_ref` servent des copies dont
|
||||||
|
`target_url` est **reconstruite à la lecture**. ⚠️ Les références
|
||||||
|
**stockées** (`data/external_refs/*.json`) gardent l'URL absolue collée
|
||||||
|
par l'utilisateur : aucune migration, la valeur stockée n'est simplement
|
||||||
|
plus jamais servie telle quelle.
|
||||||
|
- **WebAuthn — confirmé, aucun changement** : `resolve_relying_party()`
|
||||||
|
(`backend/auth/webauthn_mfa.py`, BUG-070) dérive `rp_id`/origines de la
|
||||||
|
requête (hôte exact, `X-Forwarded-Host`/`X-Forwarded-Proto` honorés si
|
||||||
|
`OBSIGATE_TRUST_PROXY=true`), et `OBSIGATE_WEBAUTHN_RP_ID` /
|
||||||
|
`OBSIGATE_WEBAUTHN_ORIGINS` priment quand ils sont posés. Aucun nom figé.
|
||||||
|
|
||||||
|
## Frontend (contrat d'injection de dracodev-commons)
|
||||||
|
|
||||||
|
L'hôte fournit les URL : `backend/main.py::_index_html()` injecte
|
||||||
|
`window.DRACO_URLS = {…}` (JSON issu de `draco_config.urls_publiques()`,
|
||||||
|
nonce CSP par le mécanisme existant) dans `index.html` au service.
|
||||||
|
|
||||||
|
- `frontend/draco-panneau.js` : `API = window.DRACO_URLS.agent || défaut officiel` ;
|
||||||
|
`appHote()` retire un suffixe `-dev` éventuel du premier label d'hôte
|
||||||
|
(`og-dev` → `obsigate` pour le lien profond `?app=`).
|
||||||
|
- `frontend/draco-liens.js` : « Ouvrir dans TikTak » =
|
||||||
|
`window.DRACO_URLS.tiktak || défaut officiel`.
|
||||||
|
- `frontend/index.html` : les `href` des onglets portent les défauts officiels ;
|
||||||
|
le script de configuration de la barre les réécrit depuis `DRACO_URLS`.
|
||||||
|
Le menu des apps (`draco-barre.js`) est construit depuis ces onglets : il
|
||||||
|
suit donc sans changement.
|
||||||
|
- Chemin réellement servi : `/static/draco-*.js` via le montage `StaticFiles`,
|
||||||
|
`Cache-Control: no-cache` posé par `SecurityHeadersMiddleware` sur
|
||||||
|
`/static/*`, et service worker **network-first** pour le code — aucun
|
||||||
|
cache-busting par empreinte n'existe (revalidation systématique).
|
||||||
|
|
||||||
|
## Correctif BUG-120 (2026-10-25) — dérivation d'environnement des sœurs
|
||||||
|
|
||||||
|
Constat sur la dev : le `.env` du poste ne déclare que l'agent (via
|
||||||
|
`AGENT_EVENEMENTS_URL`) ; les quatre `DRACO_<APP>_URL` absentes
|
||||||
|
retombaient sur les défauts prod dans `window.DRACO_URLS` — le menu
|
||||||
|
inter-apps pointait la prod depuis la dev. La résolution d'une URL
|
||||||
|
sœur (`backend/draco_config.py::app_url`) gagne une étape
|
||||||
|
intermédiaire, entre l'explicite et le défaut officiel :
|
||||||
|
**dérivation depuis l'hôte de l'agent** (`agent_url()`) — hôte de la
|
||||||
|
forme `agent<SUFFIXE>.dracodev.net` sans port → sœur =
|
||||||
|
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`, `flowdeck`,
|
||||||
|
`tiktak`, `kanbante`). Hôte hors motif (autre domaine, LAN, IP,
|
||||||
|
port) → aucune dérivation. Gitea exclue. Le dev n'a plus quatre
|
||||||
|
variables à déclarer ; un `.env` qui les déclare déjà est inchangé
|
||||||
|
(l'explicite prime).
|
||||||
|
|
||||||
|
## Restes assumés
|
||||||
|
|
||||||
|
- Suffixe SSO `dracodev.net` (`draco_session.validate_return_to`,
|
||||||
|
`frontend/js/auth.js`) : ce n'est pas un nom d'hôte mais le domaine parent
|
||||||
|
commun aux deux environnements (les noms `-dev` restent des sous-domaines) ;
|
||||||
|
le domaine du cookie est déjà configurable (`DRACO_SESSION_COOKIE_DOMAIN`).
|
||||||
|
- Mentions textuelles (titres/messages du panneau, contact OpenAPI et lien
|
||||||
|
« À propos » vers le dépôt Git) : infra Git hors périmètre, ou libellés.
|
||||||
|
- `OBSIGATE_SEARXNG_URL` : déjà configurable, défaut officiel conservé.
|
||||||
|
- Le panneau reste un asset partagé : la même ligne d'injection doit être
|
||||||
|
recopiée dans les 3 autres apps par leurs chantiers respectifs pour
|
||||||
|
retrouver l'octet-identique.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# #220 — Fusion : émission d'événements vers le journal du service d'agents
|
||||||
|
|
||||||
|
**Contexte.** Le Lot 2 du programme Fusion (carte fusion-trois-apps
|
||||||
|
#18) dote le service d'agents d'un **journal d'activité** cross-apps :
|
||||||
|
chaque application y émet ses mutations significatives au contrat
|
||||||
|
**ACC-0.2** — enveloppe `{id, type, occurred_at, actor, domain,
|
||||||
|
source_app, object{ref, label, url}}` signée **HMAC-SHA256** du corps
|
||||||
|
brut (en-tête `X-Draco-Signature`, clé propre à chaque application),
|
||||||
|
que le service expose dans la vue Journal de sa console (agent-commun
|
||||||
|
0.11.0). TikTak (0.59.0) et Kanbante (0.28.0) émettent déjà ; ObsiGate
|
||||||
|
est la troisième source, après Flowdeck.
|
||||||
|
|
||||||
|
**Principe directeur : référence, jamais contenu.** L'enveloppe ne
|
||||||
|
contient que la référence de la note (`fichier:<vault>/<chemin>`),
|
||||||
|
son nom (libellé) et un lien profond (`/#file=<vault>:<chemin>`) —
|
||||||
|
jamais son contenu.
|
||||||
|
|
||||||
|
## Garde-fou central : jamais le watcher
|
||||||
|
|
||||||
|
ObsiGate surveille les vaults par un **watcher** de système de
|
||||||
|
fichiers (`backend/watcher.py`) qui réindexe chaque changement
|
||||||
|
disque — le vault principal compte **222 393 fichiers**. Brancher
|
||||||
|
l'émission sur le watcher inonderait le journal (et confondrait les
|
||||||
|
gestes d'Obsidian, de la synchro de fichiers ou d'un éditeur externe
|
||||||
|
avec des opérations faites *dans* ObsiGate).
|
||||||
|
|
||||||
|
L'émission n'a donc lieu que pour les **opérations explicites via
|
||||||
|
l'API web**, à deux points d'accroche, et nulle part ailleurs :
|
||||||
|
|
||||||
|
| Source | Point d'accroche | Acteur |
|
||||||
|
|---|---|---|
|
||||||
|
| Interface web / clients REST | `backend/routers/files_write.py` | `user` |
|
||||||
|
| Serveur MCP d'ObsiGate (outils) | `backend/tools/service.py`, mode `mcp` | `agent:mcp` |
|
||||||
|
| Assistant intégré (outils, mode `in_app`) | `backend/tools/service.py` | `user` |
|
||||||
|
|
||||||
|
Conséquences assumées :
|
||||||
|
|
||||||
|
- une note créée/modifiée **hors API** (Obsidian, éditeur externe,
|
||||||
|
copie de fichiers) n'apparaît **pas** au journal — c'est voulu ;
|
||||||
|
- les écritures internes qui passent par la couche mutations sans
|
||||||
|
passer par ces points (planificateur, crawler, fusion de doublons,
|
||||||
|
restauration de sauvegarde) n'émettent pas non plus ;
|
||||||
|
- le test `test_watcher_et_indexation_sans_evenement` verrouille le
|
||||||
|
garde-fou : écriture disque directe + gestionnaires du watcher +
|
||||||
|
`update_single_file` de l'indexeur → **zéro émission**.
|
||||||
|
|
||||||
|
## Catalogue d'événements émis
|
||||||
|
|
||||||
|
| Type | Déclencheur |
|
||||||
|
|---|---|
|
||||||
|
| `obsigate.note.created` | `POST /api/file/{vault}` ; outil `create_file` |
|
||||||
|
| `obsigate.note.edited` | `PUT /api/file/{vault}/save` (+ variantes xlsx/csv : cellules, structure, style) ; outils `edit_file`, `append_to_file` |
|
||||||
|
| `obsigate.note.moved` | `PATCH /api/file/{vault}` (renommage), `POST /api/move/{vault}` (**fichiers seulement**) ; outils `rename_file`, `move_path` (fichiers seulement) — la référence pointe sur le **nouveau** chemin |
|
||||||
|
| `obsigate.note.deleted` | `DELETE /api/file/{vault}` ; outil `delete_file` |
|
||||||
|
|
||||||
|
Hors catalogue (non émis, volontairement) : dossiers (création,
|
||||||
|
renommage, déplacement, suppression — le catalogue est `note.*`),
|
||||||
|
téléversement par lots (`batch-upload`), duplication, restauration
|
||||||
|
de sauvegarde, `replace_in_files` (multi-fichiers).
|
||||||
|
|
||||||
|
## Module `backend/agent_journal.py`
|
||||||
|
|
||||||
|
- **Activation** : `AGENT_EVENEMENTS_CLE` (clé propre à ObsiGate,
|
||||||
|
jamais en dur). Sans clé, le module est **inactif** : aucun appel,
|
||||||
|
aucun bruit. URL surchargée par `AGENT_EVENEMENTS_URL` (défaut
|
||||||
|
`https://agent.dracodev.net/api/v1/evenements`).
|
||||||
|
- **Discipline « au mieux »** (identique à TikTak/Kanbante) : envoi
|
||||||
|
dans un fil d'arrière-plan démon via httpx, délai maximal 2 s,
|
||||||
|
exceptions avalées avec une ligne de journal local — une panne du
|
||||||
|
service d'agents ne casse ni ne ralentit jamais le métier (prouvé
|
||||||
|
par `test_recepteur_en_panne_ne_casse_rien`).
|
||||||
|
- **Domaine** : ObsiGate ne modélise pas encore le domaine d'un
|
||||||
|
vault (la question Q-A du programme — les domaines sont-ils des
|
||||||
|
vaults distincts ? — est toujours ouverte). La correspondance peut
|
||||||
|
être déclarée par `AGENT_EVENEMENTS_DOMAINES` (JSON
|
||||||
|
`{"NomDuVault": "famille"}`) ; repli `pro`, qui est aussi le
|
||||||
|
défaut du journal du service.
|
||||||
|
|
||||||
|
## Activation en production (gestes de Bruno)
|
||||||
|
|
||||||
|
1. Générer une clé propre à ObsiGate (`openssl rand -hex 32`).
|
||||||
|
2. Service d'agents : ajouter l'entrée `obsigate` à
|
||||||
|
`AGENT_EVENEMENTS_CLES` (JSON des clés par application), recréer
|
||||||
|
le conteneur.
|
||||||
|
3. ObsiGate : poser `AGENT_EVENEMENTS_CLE=<même valeur>` dans son
|
||||||
|
environnement et **redéployer** (Docker).
|
||||||
|
|
||||||
|
Tant que la clé n'est pas posée des deux côtés, ObsiGate n'émet
|
||||||
|
rien — c'est le comportement voulu.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_agent_journal.py` (7 tests) : enveloppe et signature
|
||||||
|
revérifiées comme le fait le service ; cycle de vie complet en REST
|
||||||
|
(créer → éditer → renommer → déplacer → supprimer, acteur `user`) ;
|
||||||
|
chemin outils/MCP (`create/edit/rename/delete`, acteur
|
||||||
|
`agent:mcp`) ; inactivité sans clé ; garde-fou watcher/indexation ;
|
||||||
|
panne du récepteur sans effet métier ; correspondance de domaines.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# #221 — Fusion : bouton « Console ↗ » dans le panneau Spark
|
||||||
|
|
||||||
|
**Contexte.** Le panneau Spark (#218) et la console web du service
|
||||||
|
d'agents (Lot 2, https://agent.dracodev.net/) sont deux faces du même
|
||||||
|
service : mêmes discussions, mêmes approbations, mêmes missions. Il
|
||||||
|
manquait le lien de navigation entre les deux. Demande de Bruno
|
||||||
|
(2026-10-22) : un bouton dans le panneau qui ouvre la console.
|
||||||
|
|
||||||
|
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||||
|
maintenu en canonique dans TikTak et recopié **octet-identique** dans
|
||||||
|
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
|
||||||
|
|
||||||
|
- un bouton fantôme « Console ↗ » dans la barre des discussions, à
|
||||||
|
côté de « ☰ Discussions » et « + Nouvelle » (même classe `d-btn`,
|
||||||
|
donc visible en thème clair comme sombre, le panneau suivant le
|
||||||
|
thème de l'app hôte) ;
|
||||||
|
- au clic, `ouvrirConsole()` construit l'URL **à ce moment-là** (la
|
||||||
|
discussion peut avoir changé sans recharger le panneau) :
|
||||||
|
`https://agent.dracodev.net/?disc=<id de la discussion affichée>&app=<app hôte>` ;
|
||||||
|
- la discussion vient de l'état du panneau (`convId`, repli
|
||||||
|
`sessionStorage draco_panneau_conv`) ; l'app hôte est déduite du
|
||||||
|
sous-domaine par `appHote()` (`og` → `obsigate`, `tiktak`,
|
||||||
|
`flowdeck`, `kanbante` ; hôte inconnu → pas de paramètre) ;
|
||||||
|
- ouverture en **nouvel onglet** (`window.open(..., "_blank",
|
||||||
|
"noopener")`). Sans discussion connue : lien simple vers la
|
||||||
|
racine de la console.
|
||||||
|
|
||||||
|
La console consomme déjà les deux paramètres : `?disc=` ouvre la
|
||||||
|
discussion demandée (lien profond), `?app=` s'affiche en puce
|
||||||
|
« Ouvert depuis : … » dans « Ce que Spark voit ».
|
||||||
|
|
||||||
|
**Vérifications.** Syntaxe JS (`node --check`), test fonctionnel de
|
||||||
|
la construction d'URL (4 cas : app connue/inconnue × discussion
|
||||||
|
présente/absente), test de non-régression
|
||||||
|
`tests/test_draco_barre.py::test_panneau_bouton_console_221`
|
||||||
|
(marqueurs du bouton et de la logique dans l'asset servi), somme
|
||||||
|
md5 commune des 4 copies : `c67cc2ab8e657b4e545fa93b28c3a0c4`.
|
||||||
|
|
||||||
|
**Versions.** ObsiGate 2.76.1 (cette livraison) ; TikTak 0.59.1 et
|
||||||
|
Flowdeck 7.80.1 déployées automatiquement sur release ; Kanbante
|
||||||
|
0.28.1 publiée. **Activation côté ObsiGate : redéploiement Docker
|
||||||
|
par Bruno.**
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# #222 — Fusion : panneau Spark V3, la console en surimpression pleine page
|
||||||
|
|
||||||
|
**Contexte.** Le bouton « Console ↗ » du panneau Spark (#221) ouvrait
|
||||||
|
la console web du service d'agents (https://agent.dracodev.net/)
|
||||||
|
dans un nouvel onglet : on quittait l'application en cours pour un
|
||||||
|
autre onglet. Demande de Bruno (2026-10-22) : rester dans la même
|
||||||
|
page — la console s'ouvre par-dessus l'application, avec une
|
||||||
|
animation comme celle de la barre latérale mais qui recouvre la
|
||||||
|
page au complet, et deux sorties explicites : revenir en mode
|
||||||
|
sidebar, ou fermer l'agent.
|
||||||
|
|
||||||
|
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||||
|
maintenu en canonique dans TikTak et recopié **octet-identique** dans
|
||||||
|
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
|
||||||
|
|
||||||
|
- le bouton devient « Console ⤢ » (même emplacement, barre des
|
||||||
|
discussions) et `ouvrirConsole()` n'ouvre plus d'onglet : il
|
||||||
|
monte une **surimpression** `.draco-console-sur` ancrée à droite,
|
||||||
|
née à la largeur du panneau, qui **s'étend à 100 % de la fenêtre**
|
||||||
|
par transition CSS (double `requestAnimationFrame` pour laisser
|
||||||
|
peindre la géométrie de départ) ;
|
||||||
|
- la console est chargée dans une **iframe** dont l'URL reste
|
||||||
|
construite au premier clic par `urlConsole()` : discussion
|
||||||
|
affichée (`?disc=<id>`) et app d'origine (`?app=obsigate`, déduite
|
||||||
|
du sous-domaine `og`) — le lien profond de #221 est conservé ;
|
||||||
|
- la barre de la surimpression (« ✦ Console Spark ») porte les deux
|
||||||
|
sorties demandées : **« ◧ Mode sidebar »** (`retrecirConsole()` :
|
||||||
|
l'overlay se rétracte à la largeur du panneau puis se masque,
|
||||||
|
l'iframe reste chargée) et **« ✕ Fermer l'agent »** (`fermer()` +
|
||||||
|
rétraction : panneau et console fermés) ;
|
||||||
|
- les styles de la surimpression sont **injectés par le panneau
|
||||||
|
lui-même** (`<style id="draco-console-styles">`, variables du
|
||||||
|
thème du panneau) : aucun fichier CSS à toucher côté hôte, et la
|
||||||
|
surimpression suit le thème de l'app comme le panneau.
|
||||||
|
|
||||||
|
Côté service d'agents (0.20.0) : la console autorise désormais
|
||||||
|
l'encadrement par les 4 applications (`frame-ancestors` restreint
|
||||||
|
aux sous-domaines `dracodev.net` des apps) — sans quoi l'iframe
|
||||||
|
resterait blanche.
|
||||||
|
|
||||||
|
**Vérifications.** Syntaxe JS (`node --check`) ; test
|
||||||
|
`tests/test_draco_barre.py::test_panneau_console_surimpression_222`
|
||||||
|
(marqueurs de la surimpression, des deux sorties et de l'animation
|
||||||
|
dans l'asset) ; le test #221 est révisé en conséquence (le lien
|
||||||
|
profond `?disc=` / `?app=` subsiste, l'ouverture en nouvel onglet
|
||||||
|
disparaît) ; somme md5 commune des copies :
|
||||||
|
`93026ffa14bc8ee896349e3858e12edf`.
|
||||||
|
|
||||||
|
**Versions.** ObsiGate 2.77.0 (cette livraison) ; TikTak 0.60.0 et
|
||||||
|
Flowdeck 7.81.0 déployées automatiquement sur release ; Kanbante
|
||||||
|
0.29.0 publiée. **Activation côté ObsiGate : redéploiement Docker
|
||||||
|
par Bruno.**
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# #219 — Fusion : panneau Spark v2 (Lot 1)
|
||||||
|
|
||||||
|
**Contexte.** Le panneau Spark livré en 2.74.0 (#218) affichait les
|
||||||
|
réponses de l'agent en texte brut (les `##` et `**` du Markdown
|
||||||
|
restaient visibles), avait une largeur fixe, un thème clair codé en
|
||||||
|
dur déconnecté de l'application hôte, et une seule conversation sans
|
||||||
|
gestion. Le Lot 1 du « Panneau v2 » (carte fusion-trois-apps #17,
|
||||||
|
arbitrage de Bruno du 2026-10-22) corrige ces quatre points, à
|
||||||
|
l'identique dans les quatre applications du programme.
|
||||||
|
|
||||||
|
**Livraison (côté ObsiGate : assets vendangés uniquement).**
|
||||||
|
|
||||||
|
- `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||||
|
remplacés par la v2 partagée (octet pour octet avec TikTak,
|
||||||
|
Flowdeck et Kanbante) :
|
||||||
|
- **Markdown rendu** : les réponses et les rapports de missions
|
||||||
|
affichent le HTML produit par le service d'agents (0.10.0 :
|
||||||
|
mistune + sanitizer côté serveur) ;
|
||||||
|
- **largeur réglable** à la souris (poignée, persistée,
|
||||||
|
double-clic = largeur par défaut) ;
|
||||||
|
- **thème clair/sombre aligné** sur l'application hôte
|
||||||
|
(`html[data-theme]`, ici celui d'ObsiGate, suivi en direct) ;
|
||||||
|
- **discussions façon Muse** : discussion principale
|
||||||
|
perpétuelle + discussions parallèles créées, archivées et
|
||||||
|
supprimées depuis le tiroir du panneau.
|
||||||
|
- Le travail de fond est dans le service d'agents (agent-commun
|
||||||
|
0.10.0) ; ObsiGate ne change aucun code propre. Le `/static` est
|
||||||
|
déjà servi en `Cache-Control: no-cache` : aucun versionnage
|
||||||
|
d'asset à ajouter. Redéploiement Docker requis.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# #225 — Fusion : panneau Spark, version du service d'agents visible en mode sidebar
|
||||||
|
|
||||||
|
**Contexte.** La version « Service d'agents vX » du service d'agents
|
||||||
|
est visible dans la console pleine page (pilule d'en-tête, rendue par
|
||||||
|
le service lui-même), mais pas dans le **panneau natif des
|
||||||
|
applications** (mode sidebar) — or c'est là que Bruno vit au
|
||||||
|
quotidien. Demande de Bruno (2026-10-26), carte fusion #64 : la
|
||||||
|
version doit se lire dans le panneau ouvert, sans ouvrir la console.
|
||||||
|
|
||||||
|
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||||
|
maintenu en canonique dans TikTak et recopié dans les 4 applications
|
||||||
|
(TikTak, Flowdeck, Kanbante, ObsiGate ; chaque copie garde son bloc
|
||||||
|
d'injection d'origine propre à l'hôte, le corps est commun) :
|
||||||
|
|
||||||
|
- le pied du panneau gagne une ligne dédiée
|
||||||
|
`.draco-panneau-version`, discrète (11,5 px, opacité 0,6,
|
||||||
|
monochrome, aucun emoji), **masquée tant qu'aucune version n'est
|
||||||
|
connue** ;
|
||||||
|
- à l'ouverture du panneau, `chargerVersion()` interroge la santé
|
||||||
|
du service — `GET /healthz` de l'origine que le panneau connaît
|
||||||
|
déjà (celle injectée par l'hôte, ici `window.DRACO_URLS.agent`) —
|
||||||
|
et affiche « Service d'agents vX » depuis le champ `version` de
|
||||||
|
la réponse (la même source que la pilule de la console) ;
|
||||||
|
- cache de session de 5 minutes, rafraîchissement au rythme des
|
||||||
|
ouvertures du panneau (comme la vérification d'identité) ;
|
||||||
|
- **jamais de valeur codée en dur** : réponse illisible ou service
|
||||||
|
injoignable = la ligne reste masquée — pas de fausse version, pas
|
||||||
|
d'erreur visible.
|
||||||
|
|
||||||
|
**Preuve.** Épreuve comportementale (jsdom) exécutée sur les 4
|
||||||
|
copies avec le contrat d'injection de chaque hôte : santé joignable
|
||||||
|
→ la ligne affiche « Service d'agents v0.38.0 » (version lue sur
|
||||||
|
l'origine de l'agent) ; santé injoignable → rien d'affiché ; hôte
|
||||||
|
sans injection → aucun appel. Test de garde :
|
||||||
|
`tests/test_draco_barre.py::test_panneau_version_service_affichee_225`.
|
||||||
|
|
||||||
|
**Même livraison** (carte fusion #64) : TikTak 0.61.3, Flowdeck
|
||||||
|
7.83.1, Kanbante 0.37.1. Côté ObsiGate, la prise d'effet attend le
|
||||||
|
**redéploiement Docker** (geste de Bruno), comme les items panneau
|
||||||
|
précédents.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# #224 — Sécurité des dépendances : pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
|
||||||
|
|
||||||
|
> Ouvert le 2026-10-25. Déclencheur : le job `security` du run CI 2303
|
||||||
|
> (commit `f29a228`, version 2.78.0) échoue sur `pip-audit` (bloquant,
|
||||||
|
> #87) — **17 vulnérabilités connues dans 4 paquets**. Le job `lint`
|
||||||
|
> du même run est vert ; `security` est le seul job terminé en échec
|
||||||
|
> au moment du constat (`test` en file, `build`/`e2e` en attente).
|
||||||
|
|
||||||
|
## Constat (journal du job `security`, run 2303)
|
||||||
|
|
||||||
|
| Paquet | Version auditée | Vulnérabilités | Corrigée en |
|
||||||
|
|---|---|---|---|
|
||||||
|
| brotli | 1.2.0 | PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 | 1.2.1 |
|
||||||
|
| filelock | 3.32.3 | CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 | 4.2.1 |
|
||||||
|
| opentelemetry-api | 1.37.0 | CVE-2026-114603 | 1.45.1 |
|
||||||
|
| pypdf | 6.19.0 | CVE-2026-113792, CVE-2026-113795, CVE-2026-113793, CVE-2026-113794, CVE-2026-113786, CVE-2026-113791, CVE-2026-113790, CVE-2026-113789, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580 | 6.20.0 → 6.21.2 selon l'advisory (max 6.21.2) |
|
||||||
|
|
||||||
|
Total : 2 + 3 + 1 + 11 = **17 vulnérabilités**.
|
||||||
|
|
||||||
|
Audit local de confirmation, en deux temps : (1) un venv épinglé sur
|
||||||
|
les versions du runner (brotli 1.2.0, filelock 3.32.3,
|
||||||
|
opentelemetry-api 1.37.0, pypdf 6.19.0) reproduit **exactement** les
|
||||||
|
17 vulnérabilités et les versions corrigées du tableau ci-dessus ;
|
||||||
|
(2) dans le venv du dépôt, `pip-audit` (avec les deux ignores déjà en
|
||||||
|
place dans la CI) ne ressortait avant correctif que brotli 1.2.0
|
||||||
|
(2 advisories, fix 1.2.1) et pypdf 6.21.1 (3 advisories restants,
|
||||||
|
fix 6.21.2) — ce venv a déjà filelock 4.2.4 (corrigée) et ne contient
|
||||||
|
pas opentelemetry-api. L'écart avec la CI vient de l'environnement
|
||||||
|
audité, pas des dépendances déclarées (voir cause).
|
||||||
|
|
||||||
|
## Cause racine
|
||||||
|
|
||||||
|
Même piège que BUG-093 / BUG-095, déjà documenté dans
|
||||||
|
`backend/requirements.txt` et `.gitea/workflows/ci.yml` : `pip-audit`
|
||||||
|
audite **tout l'environnement Python du runner**, dont les paquets
|
||||||
|
préinstallés dans la toolcache de l'image `catthehacker/ubuntu:act-latest`.
|
||||||
|
`pip install -r backend/requirements.txt` répond « already satisfied »
|
||||||
|
pour toute exigence transitive déjà satisfaite par la toolcache et
|
||||||
|
n'aligne jamais la version vulnérable :
|
||||||
|
|
||||||
|
- **pypdf 6.19.0** : préinstallée ; le plancher `>=6.16.1` (BUG-093)
|
||||||
|
est satisfait, pip ne monte pas vers 6.21.2.
|
||||||
|
- **brotli 1.2.0** : transitive de `fonttools[woff]` (via weasyprint),
|
||||||
|
exigée en `>=1.0.1` seulement.
|
||||||
|
- **filelock 3.32.3** : transitive de CacheControl (outil `pip-audit`
|
||||||
|
lui-même) et préinstallée ; exigée en `>=3.8.0` seulement.
|
||||||
|
- **opentelemetry-api 1.37.0** : préinstallée dans la toolcache,
|
||||||
|
aucune dépendance d'ObsiGate ne la demande.
|
||||||
|
|
||||||
|
## Correctif
|
||||||
|
|
||||||
|
Planchers de sécurité explicites dans `backend/requirements.txt`,
|
||||||
|
selon le patron déjà en place (pyjwt, urllib3, multidict, certifi) —
|
||||||
|
aucune autre dépendance touchée, aucun code applicatif modifié :
|
||||||
|
|
||||||
|
- `pypdf>=6.16.1` → **`pypdf>=6.21.2`** (plancher existant relevé) ;
|
||||||
|
- **`brotli>=1.2.1`** (nouveau plancher) ;
|
||||||
|
- **`filelock>=4.2.1`** (nouveau plancher) ;
|
||||||
|
- **`opentelemetry-api>=1.45.1`** (nouveau plancher).
|
||||||
|
|
||||||
|
Garde-fou : `tests/test_ci_workflow.py::TestDependencySecurityFloors`
|
||||||
|
couvre désormais aussi brotli, filelock et opentelemetry-api, et le
|
||||||
|
plancher verrouillé de pypdf passe à (6, 21, 2). Le commentaire de
|
||||||
|
l'étape Pip-audit de `.gitea/workflows/ci.yml` est mis à jour en
|
||||||
|
conséquence (aucune commande CI modifiée).
|
||||||
|
|
||||||
|
## Exceptions (ignores)
|
||||||
|
|
||||||
|
**Aucun nouvel ignore.** Les deux exceptions déjà en place dans la
|
||||||
|
CI restent inchangées et ne concernent pas ces paquets :
|
||||||
|
`PYSEC-2026-1325` (ecdsa, aucun correctif upstream, chemins ECDSA
|
||||||
|
jamais exécutés — HS256 uniquement) et `CVE-2026-85394`
|
||||||
|
(python-jose, aucun correctif upstream, non atteignable — voir le
|
||||||
|
commentaire de l'étape dans `ci.yml`).
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
|
||||||
|
- `pip-audit` local (mêmes ignores que la CI) après alignement de
|
||||||
|
l'environnement sur les nouveaux planchers : **0 vulnérabilité
|
||||||
|
non ignorée**.
|
||||||
|
- Portes locales du workflow : `pytest tests/`, `ruff check backend/`,
|
||||||
|
`mypy backend/ --ignore-missing-imports`, tests frontend
|
||||||
|
(`validate-imports` + `unit`) — voir le commit #224.
|
||||||
|
- CI Gitea complète verte exigée (lint, test, security, build, e2e)
|
||||||
|
avant de considérer #224 livré.
|
||||||
|
|
||||||
|
## Livraison
|
||||||
|
|
||||||
|
- Commit conventionnel `fix: … #224` ; le hook de version porte
|
||||||
|
2.78.0 → **2.78.1** (correctif) et crée le tag `v2.78.1`, publié
|
||||||
|
au push ; release Gitea créée sur ce tag après vérification de sa
|
||||||
|
cible.
|
||||||
|
- **Redéploiement Docker requis** pour que l'image embarque les
|
||||||
|
dépendances alignées (l'hôte actuel est l'environnement dev).
|
||||||
@@ -23,6 +23,12 @@
|
|||||||
kanbante: "#0d9488",
|
kanbante: "#0d9488",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Base TikTak fournie par l'hôte via window.DRACO_URLS (contrat
|
||||||
|
* d'injection dracodev-commons, #223) ; repli = défaut officiel. */
|
||||||
|
function baseTikTak() {
|
||||||
|
return (window.DRACO_URLS && window.DRACO_URLS.tiktak) || "https://tiktak.dracodev.net";
|
||||||
|
}
|
||||||
|
|
||||||
function t(key, fallback) {
|
function t(key, fallback) {
|
||||||
try {
|
try {
|
||||||
var v = window.t && window.t(key);
|
var v = window.t && window.t(key);
|
||||||
@@ -92,7 +98,7 @@
|
|||||||
t("draco.links.projected", "Tâche TikTak projetée")));
|
t("draco.links.projected", "Tâche TikTak projetée")));
|
||||||
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
|
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
|
||||||
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
|
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
|
||||||
ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1];
|
ouvrir.href = baseTikTak() + "/?task=" + tid[1];
|
||||||
ouvrir.target = "_blank";
|
ouvrir.target = "_blank";
|
||||||
ouvrir.rel = "noopener";
|
ouvrir.rel = "noopener";
|
||||||
bandeau.appendChild(ouvrir);
|
bandeau.appendChild(ouvrir);
|
||||||
|
|||||||
+143
-37
@@ -1,83 +1,189 @@
|
|||||||
/* Panneau Spark — branchement au service d'agents (Fusion, jalon J1).
|
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
|
||||||
Composant d'intégration TikTak : le panneau définitif sera vendangé
|
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
|
||||||
depuis dracodev-commons ; celui-ci prouve le chemin panneau → service. */
|
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
|
||||||
|
prefers-color-scheme) et suit les bascules en direct. Toutes les
|
||||||
|
couleurs passent par les variables ci-dessous — en-tête et fenêtre
|
||||||
|
partagent donc toujours le même mode. */
|
||||||
.draco-panneau {
|
.draco-panneau {
|
||||||
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 94vw;
|
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
|
||||||
background: #fbfbfa; color: #242624; z-index: 400;
|
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
|
||||||
|
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
|
||||||
|
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
|
||||||
|
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
|
||||||
|
--p-accent: #7c5cd6; --p-lien: #5b3df0;
|
||||||
|
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
|
||||||
|
background: var(--p-fond); color: var(--p-texte); z-index: 400;
|
||||||
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
|
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
transform: translateX(105%); transition: transform .28s ease;
|
transform: translateX(105%); transition: transform .28s ease;
|
||||||
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
|
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
.draco-panneau[data-draco-theme="dark"] {
|
||||||
|
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
|
||||||
|
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
|
||||||
|
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
|
||||||
|
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
|
||||||
|
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
|
||||||
|
--p-lien: #b39df5;
|
||||||
|
}
|
||||||
.draco-panneau.open { transform: translateX(0); }
|
.draco-panneau.open { transform: translateX(0); }
|
||||||
|
.draco-poignee {
|
||||||
|
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
|
||||||
|
cursor: ew-resize; z-index: 5; touch-action: none;
|
||||||
|
}
|
||||||
|
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
|
||||||
|
@media (max-width: 700px) { .draco-poignee { display: none; } }
|
||||||
.draco-panneau-hd {
|
.draco-panneau-hd {
|
||||||
display: flex; align-items: center; gap: 9px;
|
display: flex; align-items: center; gap: 9px;
|
||||||
padding: 13px 15px; border-bottom: 1px solid #e4e5e0; background: #fff;
|
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
}
|
}
|
||||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||||
.draco-panneau-hd .soustitre { font-size: 12px; color: #6c7268; }
|
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||||
.draco-panneau-hd .fermer {
|
.draco-panneau-hd .fermer {
|
||||||
margin-left: auto; border: 0; background: #eef0ea; border-radius: 8px;
|
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||||
width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
||||||
}
|
}
|
||||||
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
||||||
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||||
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
||||||
.draco-panneau-etat.err .point { background: #cf4b32; }
|
.draco-panneau-etat.err .point { background: #cf4b32; }
|
||||||
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; }
|
/* Barre des discussions (façon Muse) */
|
||||||
.draco-panneau-outils button {
|
.draco-disc-bar {
|
||||||
border: 1px solid #d6d8d1; background: #f4f5f1; border-radius: 999px;
|
display: flex; align-items: center; gap: 7px;
|
||||||
padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
}
|
}
|
||||||
.draco-panneau-outils button:hover { background: #e9ebe4; }
|
.draco-disc-bar .d-titre {
|
||||||
|
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.draco-disc-bar .d-btn {
|
||||||
|
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||||
|
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
|
||||||
|
}
|
||||||
|
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
|
||||||
|
.draco-discussions {
|
||||||
|
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
|
max-height: 46%; overflow-y: auto; padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.draco-discussions .d-section {
|
||||||
|
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
|
||||||
|
}
|
||||||
|
.draco-discussions .d-section:first-child { margin-top: 0; }
|
||||||
|
.draco-discussions .d-item {
|
||||||
|
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-discussions .d-item:hover { background: var(--p-chip); }
|
||||||
|
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
|
||||||
|
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
|
||||||
|
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
|
||||||
|
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
|
||||||
|
.draco-discussions .d-actions button {
|
||||||
|
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
|
||||||
|
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
|
||||||
|
.draco-discussions .d-vide, .draco-discussions .d-chargement {
|
||||||
|
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
|
||||||
|
}
|
||||||
|
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
|
||||||
|
.draco-panneau-outils button {
|
||||||
|
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||||
|
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
|
||||||
.draco-appro {
|
.draco-appro {
|
||||||
align-self: stretch; background: #fdf6ec; border: 1.6px solid #e8a13c;
|
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
|
||||||
border-radius: 12px; padding: 10px 12px;
|
border-radius: 12px; padding: 10px 12px;
|
||||||
}
|
}
|
||||||
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
|
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
|
||||||
|
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
|
||||||
.draco-appro .resume { margin: 5px 0 8px; }
|
.draco-appro .resume { margin: 5px 0 8px; }
|
||||||
.draco-appro .btns { display: flex; gap: 7px; }
|
.draco-appro .btns { display: flex; gap: 7px; }
|
||||||
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
|
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||||
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||||
.draco-auj { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
|
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||||
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 6px 0 4px; }
|
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
|
||||||
.draco-auj h5:first-child { margin-top: 0; }
|
.draco-auj h5:first-child { margin-top: 0; }
|
||||||
.draco-auj ul { margin: 0; padding-left: 17px; }
|
.draco-auj ul { margin: 0; padding-left: 17px; }
|
||||||
.draco-auj .etat-source { color: #6c7268; font-size: 12.5px; }
|
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||||
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
|
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
|
||||||
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
|
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
|
||||||
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
||||||
.draco-msg.agent { align-self: flex-start; background: #eef0ea; border-bottom-left-radius: 4px; }
|
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||||
.draco-msg.sys { align-self: center; background: none; color: #6c7268; font-size: 12.5px; text-align: center; max-width: 96%; }
|
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||||
.draco-panneau-ft { border-top: 1px solid #e4e5e0; padding: 11px 12px; background: #fff; }
|
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||||
.draco-panneau-form { display: flex; gap: 8px; }
|
.draco-panneau-form { display: flex; gap: 8px; }
|
||||||
.draco-panneau-form input {
|
.draco-panneau-form input {
|
||||||
flex: 1; border: 1px solid #d6d8d1; border-radius: 10px; padding: 9px 11px; font: inherit;
|
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||||
|
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||||
}
|
}
|
||||||
.draco-panneau-form button {
|
.draco-panneau-form button {
|
||||||
border: 0; border-radius: 10px; background: #7c5cd6; color: #fff;
|
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
||||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||||
}
|
}
|
||||||
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
||||||
.draco-panneau-note { font-size: 11.5px; color: #8a8f84; margin-top: 8px; text-align: center; }
|
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
|
||||||
.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
|
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 0 0 6px; }
|
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: #6c7268; margin: 8px 0 3px; }
|
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||||
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||||
width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px;
|
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||||
padding: 7px 9px; font: inherit; font-size: 13px; background: #fff;
|
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
|
||||||
}
|
}
|
||||||
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
|
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
|
||||||
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
|
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||||
.draco-missions .btns button.go { background: #7c5cd6; color: #fff; }
|
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
|
||||||
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; }
|
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||||
.draco-missions .m-item { border: 1px solid #e4e5e0; border-radius: 10px; padding: 8px 10px; }
|
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
|
||||||
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
|
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
|
||||||
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: #6c7268; white-space: nowrap; }
|
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
|
||||||
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
|
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
|
||||||
.draco-missions .m-statut.st-failed { color: #cf4b32; }
|
.draco-missions .m-statut.st-failed { color: #cf4b32; }
|
||||||
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
|
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
|
||||||
.draco-missions .m-meta { font-size: 12px; color: #6c7268; }
|
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
|
||||||
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
|
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
|
||||||
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: #f6e3de; color: #a03a28; }
|
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
|
||||||
|
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
|
||||||
|
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
|
||||||
|
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
|
||||||
|
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
|
||||||
|
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
|
||||||
|
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
|
||||||
|
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
|
||||||
|
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
|
||||||
|
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
|
||||||
|
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
|
||||||
|
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
|
||||||
|
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
|
||||||
|
.draco-msg.md code, .m-rapport.md code {
|
||||||
|
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
|
||||||
|
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
.draco-msg.md pre, .m-rapport.md pre {
|
||||||
|
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
|
||||||
|
overflow-x: auto; margin: 7px 0;
|
||||||
|
}
|
||||||
|
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
|
||||||
|
.draco-msg.md blockquote, .m-rapport.md blockquote {
|
||||||
|
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
|
||||||
|
color: var(--p-doux);
|
||||||
|
}
|
||||||
|
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
|
||||||
|
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
|
||||||
|
background: var(--p-chip);
|
||||||
|
}
|
||||||
|
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
|
||||||
|
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
|
||||||
|
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
|
||||||
|
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
|
||||||
|
.draco-msg.md table, .m-rapport.md table {
|
||||||
|
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
|
||||||
|
display: block; overflow-x: auto; max-width: 100%;
|
||||||
|
}
|
||||||
|
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
|
||||||
|
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
|
||||||
|
}
|
||||||
|
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
|
||||||
|
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
|
||||||
|
|||||||
+520
-46
@@ -1,43 +1,265 @@
|
|||||||
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5).
|
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
|
||||||
*
|
*
|
||||||
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
|
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
|
||||||
* GET /api/v1/me — identité (cookie draco_session)
|
* GET /api/v1/me — identité (cookie draco_session)
|
||||||
* POST /api/v1/chat — conversation réelle du noyau (J2) :
|
* POST /api/v1/chat — conversation réelle du noyau (J2) :
|
||||||
* LLM DeepSeek + outils MCP des apps
|
* LLM DeepSeek + outils MCP des apps ;
|
||||||
* GET /api/v1/conversations/… — historique de la conversation
|
* la réponse arrive avec son HTML
|
||||||
|
* Markdown rendu côté service (v2)
|
||||||
|
* GET /api/v1/conversations — discussions façon Muse (v2) : une
|
||||||
|
* principale perpétuelle + des
|
||||||
|
* parallèles créées/archivées/
|
||||||
|
* supprimées par l'utilisateur
|
||||||
|
* GET /api/v1/conversations/… — historique d'une discussion (les
|
||||||
|
* messages agent portent leur html)
|
||||||
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
||||||
* externes J4 : état de configuration)
|
* externes J4 : état de configuration)
|
||||||
* POST /api/v1/missions — missions longues (J5) : lancement,
|
* POST /api/v1/missions — missions longues (J5)
|
||||||
* suivi, rapport, annulation
|
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
||||||
|
* (persistée), thème clair/sombre aligné sur l'app hôte
|
||||||
|
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
||||||
|
* Bouton « Console » (barre des discussions) : ouvre la console web
|
||||||
|
* du même service (agent.dracodev.net) DANS la page, en surimpression
|
||||||
|
* pleine page (iframe) qui naît à la géométrie du panneau puis
|
||||||
|
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
|
||||||
|
* courante, ?app= app d'origine déduite du sous-domaine) est celle
|
||||||
|
* du premier chargement — l'iframe est créé paresseusement et
|
||||||
|
* conservé, la console garde son état entre ouvertures. La barre
|
||||||
|
* d'outils de la surimpression offre « Mode sidebar » (retour au
|
||||||
|
* panneau seul, animation inverse) et « Fermer l'agent » (referme
|
||||||
|
* surimpression et panneau).
|
||||||
* États honnêtes : service injoignable / session non reconnue / LLM non
|
* États honnêtes : service injoignable / session non reconnue / LLM non
|
||||||
* configuré / exécuteur non configuré sont affichés tels quels. */
|
* configuré / exécuteur non configuré sont affichés tels quels. */
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
var API = "https://agent.dracodev.net";
|
/* URL du service : fournie par l'hôte via window.DRACO_URLS
|
||||||
|
* (contrat d'injection dracodev-commons, #223) — le repli est le
|
||||||
|
* défaut officiel ; aucun nom d'environnement n'est figé ici. */
|
||||||
|
var API = (window.DRACO_URLS && window.DRACO_URLS.agent) || "https://agent.dracodev.net";
|
||||||
var convId = null;
|
var convId = null;
|
||||||
try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ }
|
var convKind = null; // 'principale' | 'parallele'
|
||||||
|
var convTitre = "";
|
||||||
var etatConnu = false;
|
var etatConnu = false;
|
||||||
var historiqueCharge = false;
|
|
||||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||||
|
var titreEl = null, tiroirEl = null;
|
||||||
|
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||||
|
|
||||||
function domaine() {
|
function domaine() {
|
||||||
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||||
}
|
}
|
||||||
|
function stockerConv() {
|
||||||
|
try {
|
||||||
|
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
|
||||||
|
else sessionStorage.removeItem("draco_panneau_conv");
|
||||||
|
} catch (e) { /* stockage indisponible */ }
|
||||||
|
}
|
||||||
|
function convStockee() {
|
||||||
|
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
|
||||||
|
catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Console web (même service, surimpression pleine page) ─── */
|
||||||
|
function appHote() {
|
||||||
|
/* Premier label de l'hôte, suffixe d'environnement éventuel
|
||||||
|
* retiré (ex. « og-dev » → « og » en dev, #223). */
|
||||||
|
var h = (window.location.hostname || "").split(".")[0].replace(/-dev$/, "");
|
||||||
|
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
||||||
|
kanbante: "kanbante", og: "obsigate" };
|
||||||
|
return correspondance[h] || null;
|
||||||
|
}
|
||||||
|
function urlConsole() {
|
||||||
|
var params = [];
|
||||||
|
var id = convId || convStockee();
|
||||||
|
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||||
|
var app = appHote();
|
||||||
|
if (app) params.push("app=" + encodeURIComponent(app));
|
||||||
|
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* La surimpression vit entièrement dans ce fichier (DOM + styles
|
||||||
|
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
|
||||||
|
* thème suit celui du panneau (data-draco-theme synchronisé par
|
||||||
|
* appliquerTheme) et sa palette reprend les mêmes valeurs que
|
||||||
|
* draco-panneau.css. L'iframe est créé au premier clic puis
|
||||||
|
* conservé : rouvrir la console ne le recharge pas. */
|
||||||
|
var surEl = null, surCadre = null, surOuverte = false;
|
||||||
|
var STYLES_CONSOLE =
|
||||||
|
".draco-console-sur{" +
|
||||||
|
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
|
||||||
|
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
|
||||||
|
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
|
||||||
|
"background:var(--p-fond);color:var(--p-texte);" +
|
||||||
|
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
|
||||||
|
"display:flex;flex-direction:column;transition:width .3s ease;" +
|
||||||
|
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
|
||||||
|
".draco-console-sur[data-draco-theme=\"dark\"]{" +
|
||||||
|
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
|
||||||
|
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
|
||||||
|
".draco-console-sur[hidden]{display:none}" +
|
||||||
|
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
|
||||||
|
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
|
||||||
|
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
|
||||||
|
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
|
||||||
|
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
|
||||||
|
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
|
||||||
|
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
|
||||||
|
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
|
||||||
|
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
|
||||||
|
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
|
||||||
|
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
|
||||||
|
|
||||||
|
function assurerSurimpression() {
|
||||||
|
if (surEl) return;
|
||||||
|
if (!document.getElementById("draco-console-styles")) {
|
||||||
|
var st = document.createElement("style");
|
||||||
|
st.id = "draco-console-styles";
|
||||||
|
st.textContent = STYLES_CONSOLE;
|
||||||
|
document.head.appendChild(st);
|
||||||
|
}
|
||||||
|
surEl = document.createElement("div");
|
||||||
|
surEl.className = "draco-console-sur";
|
||||||
|
surEl.setAttribute("aria-label", "Console Spark");
|
||||||
|
surEl.hidden = true;
|
||||||
|
surEl.innerHTML =
|
||||||
|
'<div class="draco-console-barre">' +
|
||||||
|
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
|
||||||
|
' <span class="dc-actions">' +
|
||||||
|
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
|
||||||
|
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
|
||||||
|
' </span></div>' +
|
||||||
|
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
|
||||||
|
document.body.appendChild(surEl);
|
||||||
|
surCadre = surEl.querySelector("iframe");
|
||||||
|
surEl.querySelector('[data-dc="sidebar"]')
|
||||||
|
.addEventListener("click", modeSidebarConsole);
|
||||||
|
surEl.querySelector('[data-dc="fermer"]')
|
||||||
|
.addEventListener("click", fermerAgentConsole);
|
||||||
|
appliquerTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
function largeurPanneau() {
|
||||||
|
if (root) {
|
||||||
|
var w = root.getBoundingClientRect().width;
|
||||||
|
if (w > 0) return Math.round(w);
|
||||||
|
}
|
||||||
|
return 388;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ouvrirConsole() {
|
||||||
|
assurerSurimpression();
|
||||||
|
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
||||||
|
if (surOuverte) return;
|
||||||
|
surOuverte = true;
|
||||||
|
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||||
|
* extension à toute la page ; le double rAF laisse le temps de
|
||||||
|
* peindre la largeur de départ, sinon la transition ne joue pas. */
|
||||||
|
surEl.style.width = largeurPanneau() + "px";
|
||||||
|
surEl.hidden = false;
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
requestAnimationFrame(function () { surEl.style.width = "100%"; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrecirConsole() {
|
||||||
|
if (!surEl || !surOuverte) return;
|
||||||
|
surOuverte = false;
|
||||||
|
var fini = false;
|
||||||
|
function terminer() {
|
||||||
|
if (fini) return;
|
||||||
|
fini = true;
|
||||||
|
surEl.removeEventListener("transitionend", surFin);
|
||||||
|
surEl.hidden = true; // l'iframe reste chargé, juste masqué
|
||||||
|
}
|
||||||
|
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
|
||||||
|
surEl.addEventListener("transitionend", surFin);
|
||||||
|
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||||
|
surEl.style.width = largeurPanneau() + "px";
|
||||||
|
}
|
||||||
|
function modeSidebarConsole() { retrecirConsole(); }
|
||||||
|
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||||
|
|
||||||
|
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||||
|
function themeHote() {
|
||||||
|
var t = document.documentElement.getAttribute("data-theme");
|
||||||
|
if (t === "dark" || t === "light") return t;
|
||||||
|
if (document.documentElement.classList.contains("dark") ||
|
||||||
|
document.body.classList.contains("dark")) return "dark";
|
||||||
|
try {
|
||||||
|
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
|
||||||
|
} catch (e) { /* pas de matchMedia */ }
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
function appliquerTheme() {
|
||||||
|
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||||
|
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
|
||||||
|
}
|
||||||
|
function surveillerTheme() {
|
||||||
|
try {
|
||||||
|
new MutationObserver(appliquerTheme).observe(document.documentElement,
|
||||||
|
{ attributes: true, attributeFilter: ["data-theme", "class"] });
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.addEventListener("change", appliquerTheme);
|
||||||
|
} catch (e) { /* surveillance facultative */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Largeur réglable ──────────────────────────────────────── */
|
||||||
|
function largeurStockee() {
|
||||||
|
try {
|
||||||
|
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
|
||||||
|
return isNaN(v) ? null : v;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function appliquerLargeur() {
|
||||||
|
if (!root) return;
|
||||||
|
var v = largeurStockee();
|
||||||
|
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
|
||||||
|
}
|
||||||
|
function brancherPoignee(poignee) {
|
||||||
|
poignee.addEventListener("pointerdown", function (ev) {
|
||||||
|
if (window.innerWidth <= 700) return; // mobile : pleine largeur
|
||||||
|
ev.preventDefault();
|
||||||
|
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
|
||||||
|
function bouger(e2) {
|
||||||
|
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
|
||||||
|
root.style.width = w + "px";
|
||||||
|
}
|
||||||
|
function lacher(e2) {
|
||||||
|
document.removeEventListener("pointermove", bouger);
|
||||||
|
document.removeEventListener("pointerup", lacher);
|
||||||
|
var w = Math.round(root.getBoundingClientRect().width);
|
||||||
|
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
|
||||||
|
}
|
||||||
|
document.addEventListener("pointermove", bouger);
|
||||||
|
document.addEventListener("pointerup", lacher);
|
||||||
|
});
|
||||||
|
poignee.addEventListener("dblclick", function () {
|
||||||
|
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
|
||||||
|
appliquerLargeur();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function construire() {
|
function construire() {
|
||||||
root = document.createElement("aside");
|
root = document.createElement("aside");
|
||||||
root.className = "draco-panneau";
|
root.className = "draco-panneau";
|
||||||
root.setAttribute("aria-label", "Spark, l'agent commun");
|
root.setAttribute("aria-label", "Spark, l'agent commun");
|
||||||
root.innerHTML =
|
root.innerHTML =
|
||||||
|
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||||
'<div class="draco-panneau-hd">' +
|
'<div class="draco-panneau-hd">' +
|
||||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||||
' <div><div class="titre">Spark — agent commun</div>' +
|
' <div><div class="titre">Spark — agent commun</div>' +
|
||||||
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||||||
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="draco-panneau-hd draco-panneau-etat" style="border-bottom:1px solid #e4e5e0;padding-top:8px;padding-bottom:8px">' +
|
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
||||||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="draco-disc-bar">' +
|
||||||
|
' <span class="d-titre">Discussion principale</span>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="draco-discussions" hidden></div>' +
|
||||||
'<div class="draco-panneau-outils">' +
|
'<div class="draco-panneau-outils">' +
|
||||||
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
|
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
|
||||||
' <button type="button" data-outil="approbations">Approbations</button>' +
|
' <button type="button" data-outil="approbations">Approbations</button>' +
|
||||||
@@ -48,21 +270,38 @@
|
|||||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||||||
' <button type="submit">Envoyer</button></form>' +
|
' <button type="submit">Envoyer</button></form>' +
|
||||||
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
||||||
|
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||||
etatEl = root.querySelector(".draco-panneau-etat");
|
etatEl = root.querySelector(".draco-panneau-etat");
|
||||||
inputEl = root.querySelector("input");
|
inputEl = root.querySelector("input");
|
||||||
sendBtn = root.querySelector("button[type=submit]");
|
sendBtn = root.querySelector("button[type=submit]");
|
||||||
|
titreEl = root.querySelector(".d-titre");
|
||||||
|
tiroirEl = root.querySelector(".draco-discussions");
|
||||||
|
versionEl = root.querySelector(".draco-panneau-version");
|
||||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||||
|
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||||
|
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
||||||
|
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
|
||||||
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
|
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
|
||||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||||
root.querySelector("form").addEventListener("submit", envoyer);
|
root.querySelector("form").addEventListener("submit", envoyer);
|
||||||
|
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||||
|
appliquerTheme();
|
||||||
|
surveillerTheme();
|
||||||
|
appliquerLargeur();
|
||||||
|
window.addEventListener("resize", appliquerLargeur);
|
||||||
document.addEventListener("keydown", function (ev) {
|
document.addEventListener("keydown", function (ev) {
|
||||||
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
|
if (ev.key !== "Escape") return;
|
||||||
|
/* Surimpression ouverte : Échap = retour au panneau seul
|
||||||
|
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
|
||||||
|
* console (autre origine), l'événement ne remonte pas
|
||||||
|
* jusqu'ici : il ne peut donc pas fermer par surprise. */
|
||||||
|
if (surOuverte) { modeSidebarConsole(); return; }
|
||||||
|
if (root.classList.contains("open")) fermer();
|
||||||
});
|
});
|
||||||
ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + ".");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ajouterMsg(kind, texte) {
|
function ajouterMsg(kind, texte) {
|
||||||
@@ -74,10 +313,55 @@
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Message agent rendu en Markdown : le HTML vient du service
|
||||||
|
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
|
||||||
|
function ajouterMsgMd(texte, html) {
|
||||||
|
var d = document.createElement("div");
|
||||||
|
d.className = "draco-msg agent md";
|
||||||
|
if (html) d.innerHTML = html;
|
||||||
|
else d.textContent = texte;
|
||||||
|
msgsEl.appendChild(d);
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
function majEtat(cls, texte) {
|
function majEtat(cls, texte) {
|
||||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||||
etatEl.querySelector(".txt").textContent = texte;
|
etatEl.querySelector(".txt").textContent = texte;
|
||||||
}
|
}
|
||||||
|
function majTitre() {
|
||||||
|
titreEl.textContent = convTitre || "Discussion principale";
|
||||||
|
titreEl.title = titreEl.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Version du service : lue en direct dans la santé de
|
||||||
|
* l'agent (GET /healthz → champ « version », la même source que
|
||||||
|
* la console) à l'origine que le panneau connaît déjà. Cache de
|
||||||
|
* session (5 min), rafraîchie à l'ouverture du panneau, au même
|
||||||
|
* rythme que la vérification d'identité. Jamais de valeur codée
|
||||||
|
* en dur : source injoignable ou réponse illisible = la ligne
|
||||||
|
* reste masquée — pas de fausse version, pas d'erreur visible. */
|
||||||
|
function afficherVersion() {
|
||||||
|
if (!versionEl) return;
|
||||||
|
if (versionConnue) {
|
||||||
|
versionEl.textContent = "Service d'agents v" + versionConnue;
|
||||||
|
versionEl.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function chargerVersion() {
|
||||||
|
if (!API || !versionEl) return;
|
||||||
|
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
|
||||||
|
fetch(API + "/healthz")
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
|
||||||
|
versionConnue = d.version;
|
||||||
|
versionMaj = Date.now();
|
||||||
|
afficherVersion();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* service injoignable : rien d'affiché */ });
|
||||||
|
}
|
||||||
|
|
||||||
function verifierIdentite() {
|
function verifierIdentite() {
|
||||||
if (etatConnu) return;
|
if (etatConnu) return;
|
||||||
@@ -109,6 +393,200 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Discussions (façon Muse) ────────────────────────────── */
|
||||||
|
function chargerListeDiscussions() {
|
||||||
|
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
|
||||||
|
{ credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function demarrerDiscussions() {
|
||||||
|
chargerListeDiscussions()
|
||||||
|
.then(function (d) {
|
||||||
|
var precedente = convStockee();
|
||||||
|
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||||
|
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||||
|
var active = trouvee || d.principale;
|
||||||
|
if (!active) return;
|
||||||
|
convId = active.id; convKind = active.kind; convTitre = active.title;
|
||||||
|
stockerConv(); majTitre();
|
||||||
|
chargerHistorique();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function basculerDiscussion(c) {
|
||||||
|
convId = c.id; convKind = c.kind; convTitre = c.title;
|
||||||
|
stockerConv(); majTitre();
|
||||||
|
tiroirEl.hidden = true;
|
||||||
|
msgsEl.innerHTML = "";
|
||||||
|
ajouterMsg("sys", (c.kind === "principale"
|
||||||
|
? "Discussion principale — le fil sans fin."
|
||||||
|
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
|
||||||
|
chargerHistorique();
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nouvelleDiscussion() {
|
||||||
|
fetch(API + "/api/v1/conversations", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ domain: domaine() })
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (c) {
|
||||||
|
basculerDiscussion(c);
|
||||||
|
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
|
||||||
|
})
|
||||||
|
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionDiscussion(id, action) {
|
||||||
|
var methode = action === "supprimer" ? "DELETE" : "POST";
|
||||||
|
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
|
||||||
|
return fetch(url, { method: methode, credentials: "include" })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
|
||||||
|
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ligneDiscussion(c, actions) {
|
||||||
|
var ligne = document.createElement("div");
|
||||||
|
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
|
||||||
|
var meta = document.createElement("div");
|
||||||
|
meta.className = "d-meta";
|
||||||
|
var quand = "";
|
||||||
|
try {
|
||||||
|
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
|
||||||
|
{ day: "numeric", month: "short" });
|
||||||
|
} catch (e) { /* date illisible */ }
|
||||||
|
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
|
||||||
|
ligne.appendChild(meta);
|
||||||
|
var sous = document.createElement("div");
|
||||||
|
sous.className = "d-sous";
|
||||||
|
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
|
||||||
|
(c.kind === "principale" ? " · perpétuelle" : "");
|
||||||
|
ligne.appendChild(sous);
|
||||||
|
meta.addEventListener("click", function () { basculerDiscussion(c); });
|
||||||
|
sous.addEventListener("click", function () { basculerDiscussion(c); });
|
||||||
|
if (actions && actions.length) {
|
||||||
|
var btns = document.createElement("div");
|
||||||
|
btns.className = "d-actions";
|
||||||
|
actions.forEach(function (a) {
|
||||||
|
var b = document.createElement("button");
|
||||||
|
b.type = "button"; b.textContent = a.label;
|
||||||
|
if (a.danger) b.className = "danger";
|
||||||
|
b.addEventListener("click", function (ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (a.confirmer && !window.confirm(a.confirmer)) return;
|
||||||
|
actionDiscussion(c.id, a.action)
|
||||||
|
.then(function () {
|
||||||
|
if (c.id === convId && a.action !== "desarchiver") {
|
||||||
|
chargerListeDiscussions().then(function (d) {
|
||||||
|
if (d.principale) {
|
||||||
|
basculerDiscussion(d.principale);
|
||||||
|
ajouterMsg("sys", a.action === "supprimer"
|
||||||
|
? "Discussion supprimée — retour à la discussion principale."
|
||||||
|
: "Discussion archivée — retour à la discussion principale.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
remplirTiroir();
|
||||||
|
})
|
||||||
|
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
|
||||||
|
});
|
||||||
|
btns.appendChild(b);
|
||||||
|
});
|
||||||
|
ligne.appendChild(btns);
|
||||||
|
}
|
||||||
|
return ligne;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remplirTiroir() {
|
||||||
|
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
|
||||||
|
chargerListeDiscussions()
|
||||||
|
.then(function (d) {
|
||||||
|
tiroirEl.innerHTML = "";
|
||||||
|
var h = document.createElement("div");
|
||||||
|
h.className = "d-section";
|
||||||
|
h.textContent = "Discussion principale";
|
||||||
|
tiroirEl.appendChild(h);
|
||||||
|
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
|
||||||
|
h = document.createElement("div");
|
||||||
|
h.className = "d-section";
|
||||||
|
h.textContent = "Discussions parallèles";
|
||||||
|
tiroirEl.appendChild(h);
|
||||||
|
if (!d.paralleles.length) {
|
||||||
|
var vide = document.createElement("div");
|
||||||
|
vide.className = "d-vide";
|
||||||
|
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
|
||||||
|
tiroirEl.appendChild(vide);
|
||||||
|
}
|
||||||
|
d.paralleles.forEach(function (c) {
|
||||||
|
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||||
|
{ label: "Archiver", action: "archive" },
|
||||||
|
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||||
|
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
if (d.archivees && d.archivees.length) {
|
||||||
|
h = document.createElement("div");
|
||||||
|
h.className = "d-section";
|
||||||
|
h.textContent = "Archivées";
|
||||||
|
tiroirEl.appendChild(h);
|
||||||
|
d.archivees.forEach(function (c) {
|
||||||
|
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||||
|
{ label: "Désarchiver", action: "desarchiver" },
|
||||||
|
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||||
|
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function basculerTiroir() {
|
||||||
|
tiroirEl.hidden = !tiroirEl.hidden;
|
||||||
|
if (!tiroirEl.hidden) remplirTiroir();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargerHistorique() {
|
||||||
|
if (!convId) return;
|
||||||
|
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 404) { convId = null; stockerConv(); return null; }
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d) return;
|
||||||
|
convKind = d.kind; convTitre = d.title || convTitre;
|
||||||
|
majTitre();
|
||||||
|
if (!msgsEl.querySelector(".draco-msg")) {
|
||||||
|
ajouterMsg("sys", (d.kind === "principale"
|
||||||
|
? "Discussion principale — le fil sans fin."
|
||||||
|
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
|
||||||
|
}
|
||||||
|
var sys = msgsEl.querySelector(".draco-msg.sys");
|
||||||
|
(d.messages || []).forEach(function (m) {
|
||||||
|
var el = m.role === "user"
|
||||||
|
? ajouterMsg("user", m.content)
|
||||||
|
: ajouterMsgMd(m.content, m.html);
|
||||||
|
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
|
||||||
|
});
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
})
|
||||||
|
.catch(function () { /* historique indisponible : on repart à neuf */ });
|
||||||
|
}
|
||||||
|
|
||||||
function chargerAujourdhui() {
|
function chargerAujourdhui() {
|
||||||
var bloc = document.createElement("div");
|
var bloc = document.createElement("div");
|
||||||
bloc.className = "draco-auj";
|
bloc.className = "draco-auj";
|
||||||
@@ -178,7 +656,7 @@
|
|||||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
bloc.style.opacity = ".55";
|
bloc.style.opacity = ".55";
|
||||||
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px;color:#6c7268'>Décision enregistrée : " + esc(d.status) + "</span>";
|
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
|
||||||
})
|
})
|
||||||
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
|
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
|
||||||
}
|
}
|
||||||
@@ -239,12 +717,24 @@
|
|||||||
d.missions.slice(0, 10).forEach(function (m) {
|
d.missions.slice(0, 10).forEach(function (m) {
|
||||||
var ligne = document.createElement("div");
|
var ligne = document.createElement("div");
|
||||||
ligne.className = "m-item";
|
ligne.className = "m-item";
|
||||||
var rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : "";
|
var entete = document.createElement("div");
|
||||||
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" +
|
entete.className = "m-entete";
|
||||||
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" +
|
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
|
||||||
"<div class='m-meta'>" + esc(m.executor) +
|
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
|
||||||
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") +
|
ligne.appendChild(entete);
|
||||||
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + rapport;
|
var meta = document.createElement("div");
|
||||||
|
meta.className = "m-meta";
|
||||||
|
meta.textContent = m.executor +
|
||||||
|
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
|
||||||
|
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
|
||||||
|
ligne.appendChild(meta);
|
||||||
|
if (m.result) {
|
||||||
|
var rapport = document.createElement("div");
|
||||||
|
rapport.className = "m-rapport md";
|
||||||
|
if (m.result_html) rapport.innerHTML = m.result_html;
|
||||||
|
else rapport.textContent = String(m.result).slice(0, 1200);
|
||||||
|
ligne.appendChild(rapport);
|
||||||
|
}
|
||||||
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
|
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
|
||||||
var btn = document.createElement("button");
|
var btn = document.createElement("button");
|
||||||
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
|
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
|
||||||
@@ -290,31 +780,6 @@
|
|||||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function chargerHistorique() {
|
|
||||||
if (historiqueCharge || !convId) return;
|
|
||||||
historiqueCharge = true;
|
|
||||||
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
|
||||||
.then(function (r) {
|
|
||||||
if (r.status === 404) {
|
|
||||||
convId = null;
|
|
||||||
try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ }
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!r.ok) return null;
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(function (d) {
|
|
||||||
if (!d || !d.messages || !d.messages.length) return;
|
|
||||||
var sys = msgsEl.querySelector(".draco-msg.sys");
|
|
||||||
d.messages.forEach(function (m) {
|
|
||||||
var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content);
|
|
||||||
if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el;
|
|
||||||
});
|
|
||||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
||||||
})
|
|
||||||
.catch(function () { /* historique indisponible : la conversation repart à neuf */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function envoyer(ev) {
|
function envoyer(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
var texte = inputEl.value.trim();
|
var texte = inputEl.value.trim();
|
||||||
@@ -347,8 +812,16 @@
|
|||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
convId = d.conversation_id;
|
convId = d.conversation_id;
|
||||||
try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ }
|
convKind = d.kind || convKind;
|
||||||
|
if (d.title) convTitre = d.title;
|
||||||
|
stockerConv(); majTitre();
|
||||||
|
if (d.html) {
|
||||||
|
attente.className = "draco-msg agent md";
|
||||||
|
attente.innerHTML = d.html;
|
||||||
|
} else {
|
||||||
attente.textContent = d.reply;
|
attente.textContent = d.reply;
|
||||||
|
}
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
if (d.tools_used && d.tools_used.length) {
|
if (d.tools_used && d.tools_used.length) {
|
||||||
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
|
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
|
||||||
}
|
}
|
||||||
@@ -362,10 +835,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ouvrir() {
|
function ouvrir() {
|
||||||
if (!root) construire();
|
if (!root) { construire(); demarrerDiscussions(); }
|
||||||
root.classList.add("open");
|
root.classList.add("open");
|
||||||
|
appliquerTheme();
|
||||||
verifierIdentite();
|
verifierIdentite();
|
||||||
chargerHistorique();
|
chargerVersion();
|
||||||
setTimeout(function () { inputEl.focus(); }, 300);
|
setTimeout(function () { inputEl.focus(); }, 300);
|
||||||
}
|
}
|
||||||
function fermer() { if (root) root.classList.remove("open"); }
|
function fermer() { if (root) root.classList.remove("open"); }
|
||||||
|
|||||||
@@ -397,6 +397,16 @@
|
|||||||
localement ; la recherche focus la recherche native
|
localement ; la recherche focus la recherche native
|
||||||
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
|
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
|
||||||
fédération / l'agent commun (phases suivantes). */
|
fédération / l'agent commun (phases suivantes). */
|
||||||
|
/* #223 : les href des onglets portent les défauts officiels ;
|
||||||
|
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
|
||||||
|
backend depuis sa configuration) quand il est présent. */
|
||||||
|
(function () {
|
||||||
|
if (!window.DRACO_URLS) return;
|
||||||
|
document.querySelectorAll(".draco-apptab").forEach(function (a) {
|
||||||
|
var u = window.DRACO_URLS[a.dataset.app];
|
||||||
|
if (u) a.href = u.replace(/\/$/, "") + "/";
|
||||||
|
});
|
||||||
|
})();
|
||||||
window.DRACO_BAR_CONFIG = {
|
window.DRACO_BAR_CONFIG = {
|
||||||
current: "obsigate",
|
current: "obsigate",
|
||||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js';
|
|||||||
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
|
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
|
||||||
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
||||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.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 { performAdvancedSearch } from './search.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
import { filterChatMessages as _filterChat } from './filechat.js';
|
import { filterChatMessages as _filterChat } from './filechat.js';
|
||||||
@@ -46,6 +47,168 @@ function _sharedIcon(size = 16) {
|
|||||||
return i;
|
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. */
|
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
|
||||||
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
|
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
|
||||||
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
|
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
|
// Replace container content in a single batch operation to avoid flash
|
||||||
container.textContent = "";
|
container.textContent = "";
|
||||||
container.appendChild(fragment);
|
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);
|
container.appendChild(fragment);
|
||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2666,5 +2666,96 @@
|
|||||||
"guide105.dl_md_title": "Download this guide as Markdown",
|
"guide105.dl_md_title": "Download this guide as Markdown",
|
||||||
"guide105.dl_pdf_title": "Download this guide as PDF",
|
"guide105.dl_pdf_title": "Download this guide as PDF",
|
||||||
"guide105.export_title": "ObsiGate User Guide",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2666,5 +2666,96 @@
|
|||||||
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
|
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
|
||||||
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
|
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
|
||||||
"guide105.export_title": "Guide d'utilisation ObsiGate",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14331,3 +14331,35 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
|||||||
color: var(--text-primary); font: inherit; font-size: 13.5px;
|
color: var(--text-primary); font: inherit; font-size: 13.5px;
|
||||||
}
|
}
|
||||||
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
|
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Coffre Secrets (Phase 1c, issue #19) — vue Secrets + nœud sidebar.
|
||||||
|
* Réutilise les variables de thème existantes ; aucune valeur de secret
|
||||||
|
* n'est jamais affichée par ces styles (colonne valeur = •••••• en texte).
|
||||||
|
* --------------------------------------------------------------------------- */
|
||||||
|
.coffres-view { padding: 16px 20px; max-width: 1100px; }
|
||||||
|
.coffres-subtitle { color: var(--text-muted); margin: 0 0 12px; }
|
||||||
|
.coffres-banner { padding: 10px 12px; margin: 12px 0; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.coffres-banner.warn { border-color: var(--warning, #f59e0b); }
|
||||||
|
.coffres-chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
||||||
|
.coffres-chip { white-space: nowrap; }
|
||||||
|
.coffres-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
|
||||||
|
.coffres-actions .modal-input { flex: 1 1 220px; }
|
||||||
|
.coffres-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||||
|
.coffres-table th, .coffres-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border, #21262d); vertical-align: top; }
|
||||||
|
.coffres-table tbody tr { cursor: pointer; }
|
||||||
|
.coffres-table tbody tr:hover { background: var(--bg-hover, #1f2430); }
|
||||||
|
.coffres-detail { margin-top: 16px; padding: 14px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; }
|
||||||
|
.coffres-dl { display: grid; grid-template-columns: 160px 1fr; gap: 4px 12px; margin: 12px 0; }
|
||||||
|
.coffres-dl dt { color: var(--text-muted); }
|
||||||
|
.coffres-dl dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.coffres-line { padding: 6px 0; border-bottom: 1px solid var(--border, #21262d); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: space-between; }
|
||||||
|
.coffres-empty { color: var(--text-muted); }
|
||||||
|
.coffres-alerts { margin: 12px 0; }
|
||||||
|
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
||||||
|
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
||||||
|
.coffres-icon-lock { font-size: 16px; line-height: 1; }
|
||||||
|
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
|
||||||
|
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
||||||
|
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||||
|
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.74.0",
|
"version": "2.80.1",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Émission d'événements vers le journal du service d'agents (#220,
|
||||||
|
Fusion Lot 2 vague 5 — contrat ACC-0.2).
|
||||||
|
|
||||||
|
Un faux récepteur local capture les POST signés HMAC et les vérifie
|
||||||
|
comme le fait le service d'agents ; on prouve aussi l'inactivité sans
|
||||||
|
clé, la non-régression métier quand le récepteur est en panne, et le
|
||||||
|
garde-fou central : le watcher / l'indexation n'émettent JAMAIS
|
||||||
|
(seules les écritures explicites via l'API ou les outils le font).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
CLE = "cle-obsigate-de-test"
|
||||||
|
|
||||||
|
|
||||||
|
class _Recepteur(BaseHTTPRequestHandler):
|
||||||
|
recus = []
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
corps = self.rfile.read(n)
|
||||||
|
type(self).recus.append(
|
||||||
|
{"headers": {k.lower(): v for k, v in self.headers.items()},
|
||||||
|
"corps": corps})
|
||||||
|
self.send_response(201)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _demarrer(monkeypatch):
|
||||||
|
_Recepteur.recus = []
|
||||||
|
serveur = HTTPServer(("127.0.0.1", 0), _Recepteur)
|
||||||
|
port = serveur.server_address[1]
|
||||||
|
threading.Thread(target=serveur.serve_forever, daemon=True).start()
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
|
||||||
|
f"http://127.0.0.1:{port}/api/v1/evenements")
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||||
|
return serveur
|
||||||
|
|
||||||
|
|
||||||
|
def _attendre(types, delai=6.0):
|
||||||
|
"""Attend que chaque type demandé soit arrivé ; retourne
|
||||||
|
{type: enveloppe} — la signature HMAC de CHAQUE envoi est
|
||||||
|
revérifiée ici, comme le fait le service."""
|
||||||
|
vus = {}
|
||||||
|
fin = time.time() + delai
|
||||||
|
while time.time() < fin and not all(t in vus for t in types):
|
||||||
|
for recu in list(_Recepteur.recus):
|
||||||
|
env = json.loads(recu["corps"].decode("utf-8"))
|
||||||
|
sig = recu["headers"].get("x-draco-signature", "")
|
||||||
|
attendu = hmac.new(CLE.encode(), recu["corps"],
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
assert hmac.compare_digest(sig.lower(), attendu), \
|
||||||
|
"signature HMAC invalide"
|
||||||
|
vus.setdefault(env["type"], env)
|
||||||
|
if not all(t in vus for t in types):
|
||||||
|
time.sleep(0.05)
|
||||||
|
manquants = [t for t in types if t not in vus]
|
||||||
|
assert not manquants, f"événements jamais reçus : {manquants}"
|
||||||
|
return vus
|
||||||
|
|
||||||
|
|
||||||
|
# ── Enveloppe ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_enveloppe_et_signature(monkeypatch):
|
||||||
|
from backend import agent_journal
|
||||||
|
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||||
|
env, corps = agent_journal.construire_enveloppe(
|
||||||
|
agent_journal.TYPE_CREE, "TestVault", "Journal/nouvelle.md")
|
||||||
|
assert env["source_app"] == "obsigate"
|
||||||
|
assert env["domain"] == "pro" # repli : aucun domaine modélisé (Q-A)
|
||||||
|
assert env["actor"] == "user"
|
||||||
|
assert env["object"] == {
|
||||||
|
"ref": "fichier:TestVault/Journal/nouvelle.md",
|
||||||
|
"label": "nouvelle.md",
|
||||||
|
"url": "/#file=TestVault:Journal/nouvelle.md",
|
||||||
|
}
|
||||||
|
assert json.loads(corps.decode("utf-8")) == env
|
||||||
|
assert env["id"] and env["occurred_at"]
|
||||||
|
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
|
||||||
|
assert len(sig) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_domaine_par_vault(monkeypatch):
|
||||||
|
from backend import agent_journal
|
||||||
|
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_DOMAINES",
|
||||||
|
json.dumps({"TestVault": "famille"}))
|
||||||
|
assert agent_journal.domaine_du_vault("TestVault") == "famille"
|
||||||
|
assert agent_journal.domaine_du_vault("AutreVault") == "pro"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Écritures REST (acteur « user ») ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_de_vie_rest(client, monkeypatch):
|
||||||
|
serveur = _demarrer(monkeypatch)
|
||||||
|
try:
|
||||||
|
r = client.post("/api/file/TestVault", json={
|
||||||
|
"path": "Journal/nouvelle.md", "content": "# Nouvelle\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.put("/api/file/TestVault/save",
|
||||||
|
params={"path": "Journal/nouvelle.md"},
|
||||||
|
json={"content": "# Nouvelle v2\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.patch("/api/file/TestVault", json={
|
||||||
|
"path": "Journal/nouvelle.md", "new_name": "renommee.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.post("/api/move/TestVault", json={
|
||||||
|
"source_path": "Journal/renommee.md", "destination_dir": "Projets"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.delete("/api/file/TestVault",
|
||||||
|
params={"path": "Projets/renommee.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
vus = _attendre([
|
||||||
|
"obsigate.note.created", "obsigate.note.edited",
|
||||||
|
"obsigate.note.moved", "obsigate.note.deleted"])
|
||||||
|
|
||||||
|
env = vus["obsigate.note.created"]
|
||||||
|
assert env["actor"] == "user"
|
||||||
|
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
|
||||||
|
assert env["object"]["label"] == "nouvelle.md"
|
||||||
|
assert env["object"]["url"] == "/#file=TestVault:Journal/nouvelle.md"
|
||||||
|
|
||||||
|
env = vus["obsigate.note.edited"]
|
||||||
|
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
|
||||||
|
|
||||||
|
# moved : émis deux fois (renommage puis déplacement). Les
|
||||||
|
# envois partent en fils d'arrière-plan — l'ordre d'arrivée
|
||||||
|
# n'est pas garanti, on compare l'ensemble des références.
|
||||||
|
refs_deplacees = {
|
||||||
|
json.loads(recu["corps"].decode("utf-8"))["object"]["ref"]
|
||||||
|
for recu in _Recepteur.recus
|
||||||
|
if json.loads(recu["corps"].decode("utf-8"))["type"]
|
||||||
|
== "obsigate.note.moved"}
|
||||||
|
assert refs_deplacees == {
|
||||||
|
"fichier:TestVault/Journal/renommee.md",
|
||||||
|
"fichier:TestVault/Projets/renommee.md"}
|
||||||
|
|
||||||
|
env = vus["obsigate.note.deleted"]
|
||||||
|
assert env["object"]["ref"] == "fichier:TestVault/Projets/renommee.md"
|
||||||
|
finally:
|
||||||
|
serveur.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Écritures par les outils / MCP (acteur « agent:mcp ») ───────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vault_outils(tmp_path, monkeypatch):
|
||||||
|
"""Vault minimal pour la couche outils (patron test_document_tools)."""
|
||||||
|
import backend.indexer
|
||||||
|
|
||||||
|
vault_dir = tmp_path / "Vault"
|
||||||
|
vault_dir.mkdir()
|
||||||
|
monkeypatch.setitem(
|
||||||
|
backend.indexer.index, "Vault",
|
||||||
|
{"name": "Vault", "path": str(vault_dir), "config": {}})
|
||||||
|
return vault_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_outils_mcp_emettent_agent_mcp(vault_outils, monkeypatch):
|
||||||
|
from backend.tools.api import ToolContext, ToolMode, call_tool
|
||||||
|
|
||||||
|
serveur = _demarrer(monkeypatch)
|
||||||
|
try:
|
||||||
|
ctx = ToolContext(
|
||||||
|
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
||||||
|
mode=ToolMode.MCP, audit_enabled=False)
|
||||||
|
|
||||||
|
out = call_tool("create_file", ctx, {
|
||||||
|
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP\n",
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
|
||||||
|
out = call_tool("edit_file", ctx, {
|
||||||
|
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP v2\n",
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
|
||||||
|
out = call_tool("rename_file", ctx, {
|
||||||
|
"vault": "Vault", "path": "par-mcp.md", "new_name": "renomme-mcp.md",
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
|
||||||
|
out = call_tool("delete_file", ctx, {
|
||||||
|
"vault": "Vault", "path": "renomme-mcp.md",
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
|
||||||
|
vus = _attendre([
|
||||||
|
"obsigate.note.created", "obsigate.note.edited",
|
||||||
|
"obsigate.note.moved", "obsigate.note.deleted"])
|
||||||
|
for env in vus.values():
|
||||||
|
assert env["actor"] == "agent:mcp"
|
||||||
|
assert vus["obsigate.note.created"]["object"]["ref"] == \
|
||||||
|
"fichier:Vault/par-mcp.md"
|
||||||
|
assert vus["obsigate.note.moved"]["object"]["ref"] == \
|
||||||
|
"fichier:Vault/renomme-mcp.md"
|
||||||
|
finally:
|
||||||
|
serveur.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Inactivité sans clé ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactif_sans_cle(client, monkeypatch):
|
||||||
|
serveur = _demarrer(monkeypatch)
|
||||||
|
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
|
||||||
|
try:
|
||||||
|
from backend import agent_journal
|
||||||
|
|
||||||
|
assert agent_journal.actif() is False
|
||||||
|
assert agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_CREE, "TestVault", "x.md") is False
|
||||||
|
r = client.post("/api/file/TestVault", json={
|
||||||
|
"path": "sans-journal.md", "content": "# Rien\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
time.sleep(0.6)
|
||||||
|
assert _Recepteur.recus == []
|
||||||
|
finally:
|
||||||
|
serveur.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Garde-fou : watcher et indexation n'émettent jamais ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_watcher_et_indexation_sans_evenement(client, test_vault_dir,
|
||||||
|
monkeypatch):
|
||||||
|
"""Une écriture disque externe (éditeur, synchro de fichiers…)
|
||||||
|
traitée par le watcher puis l'indexeur ne produit AUCUN
|
||||||
|
événement : seuls les appels explicites à l'API émettent."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
appels = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.agent_journal.emettre",
|
||||||
|
lambda *a, **k: appels.append((a, k)) or False)
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||||
|
|
||||||
|
cible = Path(test_vault_dir) / "externe.md"
|
||||||
|
cible.write_text("# Écrit hors API\n", encoding="utf-8")
|
||||||
|
|
||||||
|
# Chemin watcher : les gestionnaires d'événements filesystem.
|
||||||
|
from backend.watcher import VaultEventHandler
|
||||||
|
|
||||||
|
boucle = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
file_attente: asyncio.Queue = asyncio.Queue()
|
||||||
|
handler = VaultEventHandler("TestVault", file_attente, boucle)
|
||||||
|
evt = SimpleNamespace(is_directory=False, src_path=str(cible),
|
||||||
|
dest_path=None)
|
||||||
|
handler.on_created(evt)
|
||||||
|
handler.on_modified(evt)
|
||||||
|
|
||||||
|
# Chemin indexation : la même fonction que le watcher appelle
|
||||||
|
# après debounce pour intégrer le fichier à l'index.
|
||||||
|
from backend.indexer import update_single_file
|
||||||
|
|
||||||
|
boucle.run_until_complete(update_single_file("TestVault", "externe.md"))
|
||||||
|
finally:
|
||||||
|
boucle.close()
|
||||||
|
|
||||||
|
assert appels == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Panne du récepteur : le métier n'est jamais affecté ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch):
|
||||||
|
# Port fermé : connexion refusée dans le fil d'arrière-plan.
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
|
||||||
|
"http://127.0.0.1:59999/api/v1/evenements")
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||||
|
r = client.post("/api/file/TestVault", json={
|
||||||
|
"path": "malgre-panne.md", "content": "# OK\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.put("/api/file/TestVault/save",
|
||||||
|
params={"path": "malgre-panne.md"},
|
||||||
|
json={"content": "# OK v2\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.get("/api/file/TestVault", params={"path": "malgre-panne.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "OK v2" in r.json()["html"] # le GET renvoie le Markdown rendu
|
||||||
@@ -251,11 +251,17 @@ class TestDependencySecurityFloors:
|
|||||||
|
|
||||||
#: (paquet, plancher minimal, advisories corrigées au-dessus)
|
#: (paquet, plancher minimal, advisories corrigées au-dessus)
|
||||||
FLOORS = {
|
FLOORS = {
|
||||||
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
|
# #224 : 6.19.0 (toolcache) frappée par 11 CVE, corrigées au plus
|
||||||
|
# tard en 6.21.2 ; avant : PYSEC-2026-3910/3911 (fix 6.16.1).
|
||||||
|
"pypdf": (6, 21, 2),
|
||||||
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
|
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
|
||||||
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
|
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
|
||||||
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
|
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
|
||||||
"multidict": (6, 9, 1), # CVE-2026-104874 (fix 6.9.1 ; 6.7.x toolcache runner)
|
"multidict": (6, 9, 1), # CVE-2026-104874 (fix 6.9.1 ; 6.7.x toolcache runner)
|
||||||
|
# #224 : transitives / préinstallées toolcache, auditées par pip-audit.
|
||||||
|
"brotli": (1, 2, 1), # PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 (fix 1.2.1)
|
||||||
|
"filelock": (4, 2, 1), # CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 (fix 4.2.1)
|
||||||
|
"opentelemetry-api": (1, 45, 1), # CVE-2026-114603 (fix 1.45.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_security_floors_are_declared(self):
|
def test_security_floors_are_declared(self):
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -58,12 +58,70 @@ def test_panneau_spark_integre_218():
|
|||||||
assert "https://agent.dracodev.net" in panneau
|
assert "https://agent.dracodev.net" in panneau
|
||||||
|
|
||||||
|
|
||||||
|
def test_panneau_bouton_console_221():
|
||||||
|
# #221 : le panneau Spark (asset vendangé, octet-identique dans
|
||||||
|
# les 4 apps) porte un bouton « Console » qui mène à la console
|
||||||
|
# web du service, sur la discussion courante (?disc=) et avec
|
||||||
|
# l'app d'origine (?app= déduite du sous-domaine : og → obsigate).
|
||||||
|
# Révisé par #222 : l'ouverture en nouvel onglet a disparu (la
|
||||||
|
# console s'ouvre en surimpression, voir le test #222) ; le lien
|
||||||
|
# profond, lui, subsiste via urlConsole().
|
||||||
|
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||||
|
assert 'data-disc="console"' in panneau
|
||||||
|
assert "ouvrirConsole" in panneau
|
||||||
|
assert "urlConsole" in panneau
|
||||||
|
assert '"disc=" + encodeURIComponent(id)' in panneau
|
||||||
|
assert '"app=" + encodeURIComponent(app)' in panneau
|
||||||
|
assert 'og: "obsigate"' in panneau
|
||||||
|
assert 'window.open(' not in panneau
|
||||||
|
|
||||||
|
|
||||||
|
def test_panneau_console_surimpression_222():
|
||||||
|
# #222 : le bouton « Console ⤢ » ouvre la console dans la même
|
||||||
|
# page, en surimpression née à la largeur du panneau et étendue
|
||||||
|
# à toute la fenêtre (transition sur la largeur), avec deux
|
||||||
|
# sorties : « Mode sidebar » et « Fermer l'agent ». L'URL de
|
||||||
|
# l'iframe garde le lien profond de #221 (urlConsole()).
|
||||||
|
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||||
|
assert "Console ⤢" in panneau
|
||||||
|
assert "draco-console-sur" in panneau
|
||||||
|
assert "assurerSurimpression" in panneau
|
||||||
|
assert "draco-console-styles" in panneau
|
||||||
|
assert 'surCadre.src = urlConsole()' in panneau
|
||||||
|
assert 'surEl.style.width = "100%"' in panneau
|
||||||
|
assert 'data-dc="sidebar"' in panneau
|
||||||
|
assert "Mode sidebar" in panneau
|
||||||
|
assert "modeSidebarConsole" in panneau
|
||||||
|
assert 'data-dc="fermer"' in panneau
|
||||||
|
assert "Fermer l'agent" in panneau
|
||||||
|
assert "fermerAgentConsole" in panneau
|
||||||
|
|
||||||
|
|
||||||
def test_csp_ouvre_le_service_d_agents_218():
|
def test_csp_ouvre_le_service_d_agents_218():
|
||||||
# Le panneau parle au service d'agents : la CSP doit nommer son
|
# Le panneau parle au service d'agents : la CSP doit nommer son
|
||||||
# hôte en connect-src, sans ouvrir de générique supplémentaire.
|
# hôte en connect-src, sans ouvrir de générique supplémentaire.
|
||||||
|
# #223 : l'origine n'est plus un littéral dans main.py — elle est
|
||||||
|
# dérivée de la configuration (backend.draco_config.agent_origine,
|
||||||
|
# testée via la config dans tests/test_security_headers.py).
|
||||||
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
|
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
|
||||||
ligne = next(l for l in main.splitlines() if "connect-src" in l)
|
ligne = next(l for l in main.splitlines() if "connect-src" in l)
|
||||||
assert "https://agent.dracodev.net" in ligne
|
assert "agent_origine" in main
|
||||||
|
assert "{agent}" in ligne
|
||||||
|
|
||||||
|
|
||||||
|
def test_urls_ecosysteme_via_injection_hote_223():
|
||||||
|
# #223 : panneau Spark, liens TikTak et onglets des apps lisent
|
||||||
|
# window.DRACO_URLS (injecté par l'hôte/backend depuis sa config) ;
|
||||||
|
# les littéraux restants ne sont que les défauts officiels en repli.
|
||||||
|
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||||
|
assert "window.DRACO_URLS" in panneau
|
||||||
|
assert "DRACO_URLS.agent" in panneau
|
||||||
|
liens = (FRONTEND / "draco-liens.js").read_text(encoding="utf-8")
|
||||||
|
assert "window.DRACO_URLS" in liens
|
||||||
|
assert "baseTikTak()" in liens
|
||||||
|
html = _index()
|
||||||
|
assert "window.DRACO_URLS" in html
|
||||||
|
assert "DRACO_URLS[a.dataset.app]" in html
|
||||||
|
|
||||||
|
|
||||||
def test_rail_mobile_vers_le_vrai_hamburger():
|
def test_rail_mobile_vers_le_vrai_hamburger():
|
||||||
@@ -71,3 +129,20 @@ def test_rail_mobile_vers_le_vrai_hamburger():
|
|||||||
assert 'matchMedia("(max-width: 768px)")' in html
|
assert 'matchMedia("(max-width: 768px)")' in html
|
||||||
assert '"hamburger-btn"' in html
|
assert '"hamburger-btn"' in html
|
||||||
assert '"sidebar-toggle-btn"' in html
|
assert '"sidebar-toggle-btn"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_panneau_version_service_affichee_225():
|
||||||
|
# #225 : le pied du panneau Spark (mode sidebar) affiche la
|
||||||
|
# version du service d'agents, comme la console pleine page.
|
||||||
|
# La version est lue en direct dans la santé du service
|
||||||
|
# (GET /healthz de l'origine injectée par l'hôte), à l'ouverture
|
||||||
|
# du panneau ; jamais un littéral de version dans l'asset, et
|
||||||
|
# source injoignable = ligne laissée masquée (rien d'affiché).
|
||||||
|
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||||
|
assert "draco-panneau-version" in panneau
|
||||||
|
assert "chargerVersion" in panneau
|
||||||
|
assert 'API + "/healthz"' in panneau
|
||||||
|
assert '"Service d\'agents v" + versionConnue' in panneau
|
||||||
|
# La ligne naît masquée et n'est démasquée qu'avec une version lue.
|
||||||
|
assert 'class="draco-panneau-version"' in panneau
|
||||||
|
assert "versionEl.hidden = false" in panneau
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""URLs publiques de l'écosystème par configuration — #223 (dev/prod).
|
||||||
|
|
||||||
|
Règle : aucun nom public de l'écosystème n'est figé dans le code
|
||||||
|
vivant. Défauts = noms **officiels** (production future) ; le dev
|
||||||
|
déclare ses noms en ``-dev`` dans son seul ``.env``
|
||||||
|
(``DRACO_*_URL`` / ``AGENT_EVENEMENTS_URL``). On vérifie ici la
|
||||||
|
config centralisée, la dérivation de l'URL d'événements du journal,
|
||||||
|
la reconnaissance des liens profonds des deux environnements et la
|
||||||
|
reconstruction des URL d'affichage des références croisées.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend import draco_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env_propre(monkeypatch):
|
||||||
|
"""Aucune variable d'URL d'écosystème posée (défauts officiels)."""
|
||||||
|
for nom in (
|
||||||
|
"DRACO_AGENT_URL", "DRACO_OBSIGATE_URL", "DRACO_FLOWDECK_URL",
|
||||||
|
"DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_GITEA_URL",
|
||||||
|
"AGENT_EVENEMENTS_URL",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(nom, raising=False)
|
||||||
|
return monkeypatch
|
||||||
|
|
||||||
|
|
||||||
|
def test_defauts_officiels_sans_dev(env_propre):
|
||||||
|
assert draco_config.agent_url() == "https://agent.dracodev.net"
|
||||||
|
assert draco_config.app_url("tiktak") == "https://tiktak.dracodev.net"
|
||||||
|
assert draco_config.app_url("obsigate") == "https://og.dracodev.net"
|
||||||
|
assert draco_config.app_url("flowdeck") == "https://flowdeck.dracodev.net"
|
||||||
|
assert draco_config.app_url("kanbante") == "https://kanbante.dracodev.net"
|
||||||
|
assert draco_config.app_url("gitea") == "https://git.dracodev.net"
|
||||||
|
for url in draco_config.urls_publiques().values():
|
||||||
|
assert "-dev" not in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_derive_des_evenements(env_propre):
|
||||||
|
"""Sans DRACO_AGENT_URL, l'origine suit AGENT_EVENEMENTS_URL —
|
||||||
|
la configuration réelle du poste (dev inclus)."""
|
||||||
|
env_propre.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
assert draco_config.agent_url() == "https://agent-dev.dracodev.net"
|
||||||
|
assert draco_config.agent_origine() == "https://agent-dev.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_explicite_prime(env_propre):
|
||||||
|
env_propre.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
env_propre.setenv("DRACO_AGENT_URL", "https://agent.example.net/")
|
||||||
|
assert draco_config.agent_url() == "https://agent.example.net"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- BUG-120 : dérivation sœurs
|
||||||
|
|
||||||
|
def test_soeurs_derivees_depuis_agent_dev(env_propre):
|
||||||
|
"""BUG-120 : avec **seul** ``AGENT_EVENEMENTS_URL`` en ``-dev``
|
||||||
|
posé (aucune ``DRACO_<APP>_URL``), les URL sœurs servies suivent
|
||||||
|
l'environnement de l'agent ; Gitea ne bouge jamais."""
|
||||||
|
env_propre.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
urls = draco_config.urls_publiques()
|
||||||
|
assert urls["agent"] == "https://agent-dev.dracodev.net"
|
||||||
|
assert urls["obsigate"] == "https://og-dev.dracodev.net"
|
||||||
|
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
|
||||||
|
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
|
||||||
|
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
|
||||||
|
assert urls["gitea"] == "https://git.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_soeurs_derivees_depuis_draco_agent_url(env_propre):
|
||||||
|
"""BUG-120 : la dérivation lit l'agent via ``agent_url()`` — un
|
||||||
|
``DRACO_AGENT_URL`` en ``-dev`` suffit aussi."""
|
||||||
|
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
|
||||||
|
assert draco_config.app_url("tiktak") == "https://tiktak-dev.dracodev.net"
|
||||||
|
assert draco_config.app_url("obsigate") == "https://og-dev.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_soeur_explicite_prime_sur_derivation(env_propre):
|
||||||
|
"""BUG-120 : une ``DRACO_<APP>_URL`` explicite gagne toujours sur
|
||||||
|
la dérivation d'environnement ; les autres sœurs dérivent."""
|
||||||
|
env_propre.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
env_propre.setenv("DRACO_TIKTAK_URL", "https://taches.example.net/")
|
||||||
|
assert draco_config.app_url("tiktak") == "https://taches.example.net"
|
||||||
|
assert draco_config.app_url("flowdeck") == "https://flowdeck-dev.dracodev.net"
|
||||||
|
assert draco_config.app_url("kanbante") == "https://kanbante-dev.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("evenements", [
|
||||||
|
"https://agent.example.net/api/v1/evenements", # autre domaine
|
||||||
|
"https://agent-dev.example.com/api/v1/evenements", # suffixe hors dracodev.net
|
||||||
|
"http://deepin.lab.home:8790/api/v1/evenements", # nom LAN + port
|
||||||
|
"http://192.168.30.101:8790/api/v1/evenements", # IP + port
|
||||||
|
"https://agent-dev.dracodev.net:8790/api/v1/evenements", # port exotique
|
||||||
|
])
|
||||||
|
def test_agent_hors_motif_aucune_derivation(env_propre, evenements):
|
||||||
|
"""BUG-120 : hôte d'agent hors motif ``agent<SUFFIXE>.dracodev.net``
|
||||||
|
→ **aucune dérivation** (jamais de lien deviné), défauts officiels."""
|
||||||
|
env_propre.setenv("AGENT_EVENEMENTS_URL", evenements)
|
||||||
|
urls = draco_config.urls_publiques()
|
||||||
|
assert urls["obsigate"] == "https://og.dracodev.net"
|
||||||
|
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
|
||||||
|
assert urls["tiktak"] == "https://tiktak.dracodev.net"
|
||||||
|
assert urls["kanbante"] == "https://kanbante.dracodev.net"
|
||||||
|
assert urls["gitea"] == "https://git.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_prod_suffixe_vide_defauts_officiels(env_propre):
|
||||||
|
"""BUG-120 : hôte d'agent prod (suffixe vide) → les sœurs dérivées
|
||||||
|
sont exactement les défauts officiels."""
|
||||||
|
env_propre.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL", "https://agent.dracodev.net/api/v1/evenements")
|
||||||
|
for app, defaut in draco_config.DEFAUTS.items():
|
||||||
|
assert draco_config.app_url(app) == defaut
|
||||||
|
|
||||||
|
|
||||||
|
def test_journal_url_suit_la_config(env_propre):
|
||||||
|
from backend import agent_journal
|
||||||
|
|
||||||
|
assert agent_journal._url() == "https://agent.dracodev.net/api/v1/evenements"
|
||||||
|
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
|
||||||
|
assert agent_journal._url() == "https://agent-dev.dracodev.net/api/v1/evenements"
|
||||||
|
env_propre.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL", "https://autre.example.net/api/v1/evenements")
|
||||||
|
assert agent_journal._url() == "https://autre.example.net/api/v1/evenements"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ liens profonds
|
||||||
|
|
||||||
|
def test_parse_hotes_configures(env_propre):
|
||||||
|
"""Un lien collé depuis l'environnement dev (hôtes déclarés dans
|
||||||
|
son .env) est reconnu, comme un lien officiel."""
|
||||||
|
from backend.external_refs import parse_deep_link
|
||||||
|
|
||||||
|
env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
|
||||||
|
env_propre.setenv("DRACO_OBSIGATE_URL", "https://og-dev.dracodev.net")
|
||||||
|
assert parse_deep_link("https://tiktak-dev.dracodev.net/?task=5") == (
|
||||||
|
"tiktak", "task", "5", "TikTak · tâche 5")
|
||||||
|
assert parse_deep_link("https://og-dev.dracodev.net/#file=v:Dossier/Note.md") == (
|
||||||
|
"obsigate", "note", "v:Dossier/Note.md", "ObsiGate · Note.md")
|
||||||
|
# L'officiel reste reconnu en parallèle (lien venu de la prod).
|
||||||
|
assert parse_deep_link("https://tiktak.dracodev.net/?task=5") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_affichage_reconstruite_depuis_config(env_propre, tmp_path):
|
||||||
|
"""#223 : la référence stockée garde son URL absolue d'origine
|
||||||
|
(aucune migration), mais l'URL **servie** est reconstruite depuis
|
||||||
|
la config courante."""
|
||||||
|
from backend.external_refs import add_ref, build_deep_link, list_refs
|
||||||
|
|
||||||
|
env_propre.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
|
||||||
|
assert build_deep_link("tiktak", "task", "42") == \
|
||||||
|
"https://tiktak.dracodev.net/?task=42"
|
||||||
|
assert build_deep_link("kanbante", "card", "Projets/obsigate#7") == \
|
||||||
|
"https://git.dracodev.net/Projets/obsigate/issues/7"
|
||||||
|
assert build_deep_link("obsigate", "note", "v:Dossier/Note.md") == \
|
||||||
|
"https://og.dracodev.net/#file=v:Dossier%2FNote.md"
|
||||||
|
|
||||||
|
env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
|
||||||
|
ref = add_ref("alice", "Vault", "N.md", "tiktak", "task", "42",
|
||||||
|
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
|
||||||
|
assert ref["target_url"] == "https://tiktak.dracodev.net/?task=42" # stocké tel quel
|
||||||
|
servies = list_refs("alice", "Vault", "N.md")
|
||||||
|
assert servies[0]["target_url"] == "https://tiktak-dev.dracodev.net/?task=42"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ injection hôte
|
||||||
|
|
||||||
|
def test_index_injecte_draco_urls(client, env_propre):
|
||||||
|
"""L'hôte sert index.html avec window.DRACO_URLS issu de sa config.
|
||||||
|
|
||||||
|
BUG-120 : l'agent en ``-dev`` entraîne les sœurs en ``-dev``
|
||||||
|
(dérivation d'environnement) — avant, ``tiktak`` restait en prod.
|
||||||
|
"""
|
||||||
|
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "window.DRACO_URLS" in resp.text
|
||||||
|
assert '"agent": "https://agent-dev.dracodev.net"' in resp.text
|
||||||
|
assert '"tiktak": "https://tiktak-dev.dracodev.net"' in resp.text
|
||||||
|
assert '"gitea": "https://git.dracodev.net"' in resp.text
|
||||||
@@ -72,3 +72,59 @@ def test_security_headers_present(client):
|
|||||||
csp = resp.headers.get("content-security-policy", "")
|
csp = resp.headers.get("content-security-policy", "")
|
||||||
assert "object-src 'none'" in csp
|
assert "object-src 'none'" in csp
|
||||||
assert "frame-ancestors 'self'" in csp
|
assert "frame-ancestors 'self'" in csp
|
||||||
|
|
||||||
|
|
||||||
|
def _csp_directives(csp: str) -> dict[str, list[str]]:
|
||||||
|
"""Découpe une politique CSP en ``{directive: [sources]}``."""
|
||||||
|
directives: dict[str, list[str]] = {}
|
||||||
|
for part in csp.split(";"):
|
||||||
|
tokens = part.split()
|
||||||
|
if tokens:
|
||||||
|
directives[tokens[0]] = tokens[1:]
|
||||||
|
return directives
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_frame_src_allows_agent_console(client, monkeypatch):
|
||||||
|
"""BUG-119 : le « mode console » du panneau Spark charge la console du
|
||||||
|
service d'agents (``https://agent.dracodev.net``) dans une iframe —
|
||||||
|
``frame-src`` doit autoriser cette origine, sinon le navigateur bloque
|
||||||
|
le chargement (« This content is blocked »). Les sources historiques
|
||||||
|
(``'self'`` et ``blob:``, visualiseur PDF) restent en place et aucune
|
||||||
|
autre origine n'est admise.
|
||||||
|
|
||||||
|
#223 : la configuration est neutralisée ici (défaut officiel) —
|
||||||
|
l'origine n'est plus figée dans le code, voir le test via config
|
||||||
|
ci-dessous."""
|
||||||
|
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
|
||||||
|
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
csp = resp.headers.get("content-security-policy", "")
|
||||||
|
directives = _csp_directives(csp)
|
||||||
|
assert directives.get("frame-src") == [
|
||||||
|
"'self'",
|
||||||
|
"blob:",
|
||||||
|
"https://agent.dracodev.net",
|
||||||
|
]
|
||||||
|
assert "https://agent.dracodev.net" in directives.get("connect-src", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_agent_origine_via_config(client, monkeypatch):
|
||||||
|
"""#223 : ``frame-src``/``connect-src`` suivent l'origine de l'agent
|
||||||
|
**configurée** — dérivée d'``AGENT_EVENEMENTS_URL`` (config réelle
|
||||||
|
du poste, noms en ``-dev`` en dev), ou de ``DRACO_AGENT_URL`` qui
|
||||||
|
prime. Aucun nom d'environnement n'est codé en dur."""
|
||||||
|
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
directives = _csp_directives(resp.headers.get("content-security-policy", ""))
|
||||||
|
assert directives.get("frame-src") == [
|
||||||
|
"'self'", "blob:", "https://agent-dev.dracodev.net"]
|
||||||
|
assert "https://agent-dev.dracodev.net" in directives.get("connect-src", [])
|
||||||
|
assert "https://agent.dracodev.net" not in directives.get("connect-src", [])
|
||||||
|
|
||||||
|
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.example.net")
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
directives = _csp_directives(resp.headers.get("content-security-policy", ""))
|
||||||
|
assert directives.get("frame-src") == [
|
||||||
|
"'self'", "blob:", "https://agent.example.net"]
|
||||||
|
|||||||
Reference in New Issue
Block a user