Files
ObsiGate/docs/features/directed-shares-196.md
T
bruno 2be0b15bcf
CI / lint (push) Successful in 3m0s
CI / security (push) Failing after 1m38s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 19m55s
fix: partage dirigé — ACL court-circuitée après résolution Partage/ + chemin canonique au token #196
- le partage EST l'autorisation: plus de 403 'Accès refusé à la vault' pour un
  destinataire sans accès au vault émetteur (api_file, raw, download)
- chemin virtuel canonique Partage/<token>/<nom> — désambiguïse les homonymes,
  fallback par nom conservé
- 'reçu' = partagé_avec contient l'utilisateur: un admin ne voit plus les
  partages des autres comme reçus (fix File not found: Partage/…)
- +3 tests (14 total)
2026-10-10 23:11:12 -04:00

8.1 KiB

#196 — Partage dirigé entre utilisateurs

Statut : livré | Ouvert : 2026-10-10 | Type : feature (sharing + auth)

Problème

Le seul partage existant (#85) était un lien public : quiconque possède l'URL lit le document, sans compte ni session. Il n'existait aucun moyen de partager un document à un utilisateur précis de la plateforme — un utilisateur ne pouvait ni adresser un document à un autre, ni consulter les documents qu'on lui adresse, ni (côté créateur) retirer ce partage.

Décision d'architecture

Deux formes étaient possibles :

  1. Réutiliser le partage par token existant, dirigé (retenu) — le record data/shares.json gagne shared_with: [usernames]. Si la liste est non vide, les pages /s/{token} (et /pdf, /raw) exigent une session et n'acceptent que créateur + admin + destinataires. Vide/absent : le comportement public historique est inchangé (rétrocompatibilité totale, zéro migration — les shares existants n'ont pas le champ).
  2. Octroyer le vault entier au destinataire (update_user {"vaults": …}) (écartée) — accès lecture-écriture au vault complet au lieu d'un document en lecture seule ; c'est le modèle « collaborateur de vault », pas « partager un document ». Reste la bonne réponse pour ce besoin-là.

Points de sécurité délibérés :

  • 404, jamais 403/401, sur la gate — un visiteur sans session (ou un utilisateur non destinataire) apprend le même « Share not found or expired » qu'un token inventé : l'existence d'un token dirigé n'est pas confirmable de l'extérieur.
  • GET /api/shares scopé (correctif au passage) — un non-admin ne voit que les partages qu'il a créés ou reçus` (avant : tout le monde voyait tous les partages, fuite préexistante devenue intenable avec du user-to-user). Un admin voit tout.
  • Révocation réservée au créateur/admin — un destinataire ne peut pas casser l'accès des autres.
  • Les destinataires sont validés à la création (get_user) : pas de lien mort vers un compte inexistant.

Mise en place

Élément Où
Store : champ shared_with, param de create_share backend/share.py
Gate _gate_share() + branchement /s/{token}, /s/{token}/pdf, /s/{token}/raw backend/routers/sharing.py
Scope de GET /api/shares (non-admin = créés + reçus) backend/routers/sharing.py, backend/share.py::list_shares(user=…)
Révocabilité créateur/admin uniquement backend/routers/sharing.py::api_share_revoke
Validation des destinataires à la création backend/routers/sharing.py::api_share_create
ShareModel.shared_with backend/schemas.py
Dialogue : radio public/dirigé + champ destinataires (virgules) frontend/js/config.js::openShareDialog
Destinataires affichés dans la vue « déjà partagé » frontend/js/config.js
Dashboard : cartes reçues (« Partagé par X », ouverture /s/, pas de bouton révoquer) frontend/js/dashboard.js::DashboardSharedWidget
i18n FR/EN (6 clés config.* + dashboard.shared_by) frontend/locales/{fr,en}.json

Ce qui est gratuit par construction : lecture seule (le destinataire ne passe jamais par l'éditeur), expiration, compteur d'accès, suivi des renommages (update_shares_after_rename), masquage des secrets (redact_file_content déjà dans les 3 routes /s/*), isolation des dossiers perso #194 (le destinataire ne voit jamais l'arbre du vault de l'auteur, seulement le document rendu).

Tests

tests/test_directed_shares.py (9) : création dirigée, destinataire inconnu → 400, anonyme → 404 / destinataire + créateur → 200, non-destinataire → 404, lien public toujours anonyme, scope de /api/shares, destinataire ne peut pas révoquer (403), créateur révoque, la révocation coupe l'accès.

Limites connues

  • shared_with est une liste plate d'usernames — pas de groupes. Ajouter des groupes quand le besoin sera réel.
  • Pas de notification push : le destinataire découvre les partages reçus au rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe si un vrai push est demandé).
  • La date d'« expiration » reste globale au partage (pas par destinataire).

Itération 2 — visibilité & recherche (2026-10-11)

Élément Où
Icône « partagé » (share-2 bleu) sur les fichiers de l'arborescence frontend/js/sidebar.js (loadSharedPaths + sharedFileIconSync, appliqué dans loadDirectory et incrementalLoadDirectory)
Dossier virtuel Partage à la racine du home du destinataire frontend/js/sidebar.js (injection virtual-share + rendu, clic fichier → /s/{token})
Recherche des documents reçus backend/services/search.py::_shared_results (merge dans search_vaults avant pagination), backend/routers/search.py (passe username), SearchResultItem.share_token
Clic sur un résultat reçu → page de partage frontend/js/search.js
Invalidation du cache front invalidateSharedPaths() exporté, appelé par openShareDialog après création/révocation

Décisions :

  • Dossier virtuel, pas physique : l'indexeur exclut les symlinks (sécurité path traversal) et un dossier réel ferait double indexation. La source de vérité reste data/shares.json.
  • Recherche à la volée (_shared_results) : le contenu des documents reçus est lu au moment de la requête, uniquement pour les destinataires — jamais indexé dans le TF-IDF global, donc aucune fuite vers un non-destinataire (testé). ponytail: lecture disque par requête, plafond ~quelques centaines de partages reçus par user ; indexer dans le TF-IDF avec un champ "destinataires" si le volume devient réel.
  • Icône déterminée côté client via /api/shares (déjà scopé #196) : la liste des partages actifs "créés OU reçus" suffit — le créateur voit l'icône sur son fichier source, le destinataire voit le dossier Partage.

+2 tests : test_search_finds_received_share_for_recipient, test_search_does_not_leak_share_to_other_user.

Itération 3 — ouverture en onglet (2026-10-11)

  • Résolution serveur : _resolve_shared_file() (backend/routers/files_read.py) mappe home-<user>/Partage/<fichier> vers le fichier source pour les routes /api/file, /api/file/raw, /api/file/download. Résolu AVANT l'ACL vault (le home virtuel peut ne pas être dans user.vaults) — l'autorisation réelle est l'appartenance au partage, vérifiée dans le resolver (destinataire seul).
  • Front : clic dossier Partage → simple dépliage (plus de openNav, fix « Directory not found ») ; clic fichier reçu → TabManager.openPreview/openPersistent (arbre, recherche, dashboard) — même parcours qu'un fichier ordinaire.
  • POST /api/share : path vide/null → 400 (un share path: None cassait /api/shares en 500). +2 tests : ouverture applicative du fichier reçu (file + raw), résolution scopée par utilisateur (home d'autrui → 403).

Itération 4 — correctifs d'ouverture (2026-10-11)

  • Le partage EST l'autorisation : après résolution Partage/…, l'ACL du vault source n'est plus appliquée (elif dans /api/file, raw, download) — un destinataire sans accès au vault émetteur ouvrait « Accès refusé à la vault '…' », précisément le cas d'usage du partage. L'appartenance au partage (vérifiée dans _resolve_shared_file via list_shares(user=…)) reste la seule autorisation.
  • Chemin canonique Partage/<token>/<nom> : lève l'ambiguïté de deux partages homonymes (le token identifie le partage exact). Fallback par nom conservé pour les liens historiques. Transporté par l'arbre (data-path), la recherche et le dashboard.
  • « Reçu » = adressé à moi : sidebar _loadReceivedShares et dashboard filtrent désormais shared_with.includes(me) — un admin voyant TOUS les partages (/api/shares non scopé pour lui) n'a plus les partages d'autres comptes dans son dossier Partage (source des « File not found »). +3 tests : destinataire sans accès au vault source (200), disambiguïsation par token, home d'autrui (403). 14 tests au total.