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

144 lines
8.1 KiB
Markdown

# #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.