diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa9f8c..80fafbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.65.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.65.1**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.65.1] — 2026-10-10 + +--- + ## [2.65.0] — 2026-10-10 ### Ajouté @@ -31,6 +35,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Tests : `sidebar-filters.test.mjs` +2 (icône maison pour `home-*`, gemme violette inchangée ailleurs). +### Corrigé + +- **#194 — Un dossier perso n'est plus visible par les autres comptes** + - `check_vault_access` (`backend/auth/middleware.py`) refuse désormais tout + vault `home-*` qui n'est pas **explicitement** octroyé au compte : le + `vaults: ["*"]` de l'admin ne couvre plus les dossiers persos (avant, + l'admin voyait `home-` de chaque utilisateur dans sa barre latérale). + - Les filtres `« * »` écrits en dur dans les routes ont été remplacés par + cet appel unique — dashboard, conflits, liens retour, favoris, + abonnements push : partout la même règle. + - `/api/search` filtre les résultats par vault accessible **avant** + pagination (`search_vaults(is_allowed=…)`), sinon les notes personnelles + d'un autre pouvait apparaître dans les résultats. + - `_grant` octroie désormais le home aussi aux comptes `« * »` : l'admin + voit **son** `home-admin`, réparé au démarrage pour les comptes existants. + - Tests : `test_user_home.py` +2 (`« * »` sans homes, filtrage de la + recherche), assertion API inversée dans `test_auth_api.py`. + --- ## [2.64.0] — 2026-10-10 diff --git a/README.fr.md b/README.fr.md index 87d7a8b..e554a51 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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. -[![Version](https://img.shields.io/badge/Version-2.65.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.65.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.65.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.65.1). --- -*Projet : ObsiGate | Version : 2.65.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.65.1 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 96e6dd1..c1715fc 100644 --- a/README.md +++ b/README.md @@ -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. -[![Version](https://img.shields.io/badge/Version-2.65.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.65.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.65.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.65.1). --- -*Project: ObsiGate | Version: 2.65.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.65.1 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 9fbc3d9..7f77fdc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.65.0 +2.65.1 diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index 505a506..e2d79a8 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -142,6 +142,11 @@ def require_admin(current_user=Depends(require_auth)): return current_user +def is_home_vault(vault_name: str) -> bool: + """Un dossier personnel (#194) : vault « home- ».""" + return vault_name.startswith("home-") + + def check_vault_access(vault_name: str, user: dict) -> bool: """Check if a user has access to a specific vault. @@ -149,8 +154,13 @@ def check_vault_access(vault_name: str, user: dict) -> bool: - vaults == ["*"] → full access (admin default) - vault_name in vaults → access granted - otherwise → denied + + #194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon + un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur. """ vaults = user.get("_token_vaults") or user.get("vaults", []) + if is_home_vault(vault_name): + return vault_name in vaults if "*" in vaults: return True return vault_name in vaults diff --git a/backend/push.py b/backend/push.py index 92fdd21..5670368 100644 --- a/backend/push.py +++ b/backend/push.py @@ -13,7 +13,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from backend.auth.middleware import require_auth +from backend.auth.middleware import check_vault_access, require_auth logger = logging.getLogger("obsigate.push") @@ -167,9 +167,9 @@ async def subscribe_push( """Subscribe to push notifications for a vault.""" username = current_user.get("username", "unknown") - # Check if user has access to this vault - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) - if "*" not in user_vaults and request.vault not in user_vaults: + # Check if user has access to this vault (#194 : "*" n'inclut pas les + # dossiers persos, check_vault_access est la source unique). + if not check_vault_access(request.vault, current_user): raise HTTPException(status_code=403, detail="No access to this vault") # Check if subscription already exists diff --git a/backend/routers/config.py b/backend/routers/config.py index f8f34a6..67be18f 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -24,7 +24,7 @@ from pathlib import Path from fastapi import APIRouter, Body, Depends, HTTPException, Query from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key -from backend.auth.middleware import require_admin, require_auth +from backend.auth.middleware import check_vault_access, require_admin, require_auth from backend.indexer import index from backend.media_types import IMAGE_EXTENSIONS from backend.schemas import ( @@ -495,14 +495,13 @@ async def api_diagnostics(current_user=Depends(require_admin)): @router.get("/api/dashboard", response_model=DashboardResponse) async def api_dashboard(current_user=Depends(require_auth)): """Aggregated dashboard statistics across all accessible vaults.""" - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) vault_stats = [] total_files = 0 total_tags = set() total_size = 0 total_images = 0 for vname, vdata in index.items(): - if "*" not in user_vaults and vname not in user_vaults: + if not check_vault_access(vname, current_user): continue files = vdata.get("files", []) fc = len(files) diff --git a/backend/routers/conflicts.py b/backend/routers/conflicts.py index d48eae5..3b11cc3 100644 --- a/backend/routers/conflicts.py +++ b/backend/routers/conflicts.py @@ -31,10 +31,9 @@ router = APIRouter(tags=["conflicts"]) @router.get("/api/conflicts", response_model=ConflictsResponse) async def api_conflicts(current_user=Depends(require_auth)): """List sync-conflict files across accessible vaults.""" - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) all_conflicts = get_conflicts() - if "*" not in user_vaults: - all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults] + # #194 : filtrage via check_vault_access ("*" n'inclut pas les homes). + all_conflicts = [c for c in all_conflicts if check_vault_access(c["vault"], current_user)] return {"conflicts": all_conflicts, "total": len(all_conflicts)} diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index ab4ac6b..18a34de 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -166,12 +166,11 @@ async def api_file_backlinks( if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) backlinks = get_backlinks(vault_name, path) - # Filter by user-accessible vaults - if "*" not in user_vaults: - backlinks = [b for b in backlinks if b["vault"] in user_vaults] + # Filter by user-accessible vaults (#194 : check_vault_access, "*" sans + # les dossiers persos). + backlinks = [b for b in backlinks if check_vault_access(b["vault"], current_user)] return { "vault": vault_name, diff --git a/backend/routers/history.py b/backend/routers/history.py index 340a56d..b7010ae 100644 --- a/backend/routers/history.py +++ b/backend/routers/history.py @@ -59,7 +59,6 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query( @router.get("/api/bookmarks", response_model=BookmarksResponse) async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)): username = current_user.get("username") - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) if not username: return {"files": []} @@ -68,7 +67,8 @@ async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(re files_resp = [] for item in history: v_name = item["vault"] - if "*" not in user_vaults and v_name not in user_vaults: + # #194 : check_vault_access — "*" n'inclut pas les dossiers persos. + if not check_vault_access(v_name, current_user): continue # Find in index to get metadata diff --git a/backend/routers/search.py b/backend/routers/search.py index 72927e5..e9a3c57 100644 --- a/backend/routers/search.py +++ b/backend/routers/search.py @@ -82,7 +82,12 @@ async def api_search( # paginate in the shared service so routes and tools share the same logic. return await loop.run_in_executor( get_search_executor(), - partial(search_vaults, q, vault, tag, limit, offset), + # #194 : filtre par vault accessible AVANT pagination ("*" sans les + # dossiers persos) — sinon un user voyait les notes des autres. + partial( + search_vaults, q, vault, tag, limit, offset, + is_allowed=lambda v: check_vault_access(v, current_user), + ), ) diff --git a/backend/services/recent.py b/backend/services/recent.py index 4d95a43..7c4b00f 100644 --- a/backend/services/recent.py +++ b/backend/services/recent.py @@ -12,6 +12,7 @@ import time from datetime import datetime from typing import Any +from backend.auth.middleware import is_home_vault from backend.history import get_recent_opened, is_bookmarked from backend.indexer import find_file_in_index, index @@ -31,6 +32,10 @@ def humanize_mtime(mtime: float) -> str: def _can_access(vault: str, user_vaults: list[str]) -> bool: + # #194 : un dossier perso ne bénéficie jamais de "*" (même règle que + # backend.auth.middleware.check_vault_access). + if is_home_vault(vault): + return vault in user_vaults return "*" in user_vaults or vault in user_vaults diff --git a/backend/services/search.py b/backend/services/search.py index fe47cf5..7cb0f0b 100644 --- a/backend/services/search.py +++ b/backend/services/search.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any @@ -11,15 +12,19 @@ def search_vaults( tag: str | None = None, limit: int = 50, offset: int = 0, + is_allowed: Callable[[str], bool] | None = None, ) -> dict[str, Any]: """Full-text search with pagination, returned as the API response payload. - No permission filtering is applied here: callers that need it (the tool - layer) filter the ``results`` list themselves. + ``is_allowed`` (#194) filters the raw hits **before** pagination, so a + restricted vault set neither distorts ``total`` nor the returned page. + The tool layer filters on its own and leaves it as ``None``. """ from backend.search import search all_results = search(q, vault_filter=vault, tag_filter=tag) + if is_allowed is not None: + all_results = [r for r in all_results if is_allowed(r.get("vault", ""))] total = len(all_results) page = all_results[offset: offset + limit] return { diff --git a/backend/user_home.py b/backend/user_home.py index 4af4729..0669893 100644 --- a/backend/user_home.py +++ b/backend/user_home.py @@ -112,14 +112,18 @@ async def release_user_home(username: str) -> None: def _grant(username: str, vault_name: str) -> None: - """Ajoute le vault à ``user.vaults`` si l'utilisateur n'a pas déjà ``*``.""" + """Ajoute le vault à ``user.vaults`` s'il n'y est pas déjà (#194). + + L'octroi est explicite même pour un admin (``vaults: ["*"]``) : ``*`` ne + couvre jamais un dossier perso (voir ``check_vault_access``). + """ from backend.auth.user_store import get_user, update_user user = get_user(username) if not user: return # créé hors API (bootstrap avant users.json) → réparé au boot suivant vaults = user.get("vaults") or [] - if "*" in vaults or vault_name in vaults: + if vault_name in vaults: return update_user(username, {"vaults": [*vaults, vault_name]}) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 2ddac84..acdac9d 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.65.0" +version = "2.65.1" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 02ec951..3cf0e32 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.65.0" +version = "2.65.1" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 56012de..9aa1aa8 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.65.0", + "version": "2.65.1", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 69d372a..085a9c4 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -343,6 +343,8 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `` remplacé) | ✅ 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-` 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) | + --- ## 📜 Historique des bugs résolus diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 02ed149..6beb8d2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.65.0 | **Dernière mise à jour :** 2026-10-10 +> **Version :** 2.65.1 | **Dernière mise à jour :** 2026-10-10 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -50,6 +50,9 @@ `docker-compose.test-win.yml` → `test_home:/vaults/Home`) - [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison lucide (`getVaultIcon`), vérifié live sur l'instance 2020 + - [x] Isolation : « * » (admin) ne couvre **plus** les dossiers persos — + `check_vault_access` + filtres des routes + `/api/search` (avant + pagination) ; octroi du home aussi aux comptes « * » - [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs --- diff --git a/docs/features/user-home-194.md b/docs/features/user-home-194.md index e490360..265c0b4 100644 --- a/docs/features/user-home-194.md +++ b/docs/features/user-home-194.md @@ -81,9 +81,24 @@ suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home` montée `/vaults/Home`. - **Test local** : `docker-compose.test-win.yml` monte `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (`test_home/` est dans `.gitignore`). -- **Vérifié live** (instance 2020, Playwright) : ordre - `home-admin, home-bruno, TestDir, TestVault`, `svg.lucide-home` rendu, - placeholder `` remplacé. +- **Vérifié live** (instance 2020, Playwright) : ordre `home-admin, TestDir, + TestVault` (l'admin ne voit plus le home des autres), `svg.lucide-home` + rendu, placeholder `` remplacé. + +## Isolation — « * » ne couvre pas les dossiers persos + +- **Racine** : `check_vault_access` (`backend/auth/middleware.py`) renvoie + ``vault_name in vaults`` pour tout vault ``home-*``, quel que soit le ``*``. + Toutes les routes (89 usages) passent par cette fonction. +- **Filtres « * » en dur remplacés** par cet appel : dashboard + (`routers/config.py`), conflits, liens retour, favoris, abonnements push. +- **Recherche** : `services/search.search_vaults(is_allowed=…)` filtre les + bruts **avant** pagination — ``total`` et page restent justes. +- **Octroi** : `_grant` n'écarte plus les comptes ``*`` → l'admin reçoit son + propre ``home-admin`` (réparé au démarrage pour les comptes existants). +- **Reste ouvert** (fuite pré-existante, hors #194) : `/api/search/advanced`, + `/api/tags`, `/api/tags/suggest`, `/api/tree-search` et `/api/suggest` ne + filtrent pas par vault accessible — à traiter dans une tâche dédiée. ## Impact utilisateur diff --git a/package.json b/package.json index 7d45b15..1406795 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.65.0", + "version": "2.65.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.", "main": "patch.js", "directories": { diff --git a/tests/test_auth_api.py b/tests/test_auth_api.py index ee3bc0c..c712fcd 100644 --- a/tests/test_auth_api.py +++ b/tests/test_auth_api.py @@ -514,8 +514,11 @@ class TestUserHome: assert (home / "alice").is_dir() names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()] - assert "home-alice" in names - # L'octroi atterrit bien dans le user → visible dans son JWT/liste de vaults + # #194 : « * » n'inclut pas les dossiers persos — l'admin ne voit pas + # le home d'alice (il ne verrait sinon celui de chaque utilisateur). + assert "home-alice" not in names + assert "TestVault" in names + # L'octroi atterrit bien dans le user assert "home-alice" in resp.json()["vaults"] # Isolation : alice ne voit QUE son propre dossier diff --git a/tests/test_user_home.py b/tests/test_user_home.py index 1ffed24..6819227 100644 --- a/tests/test_user_home.py +++ b/tests/test_user_home.py @@ -191,6 +191,48 @@ class TestIsolation: assert check_vault_access("home-alice", get_user("bob")) is False assert check_vault_access("home-bob", get_user("alice")) is False + def test_wildcard_admin_does_not_cover_homes(self, home_env): + """#194 : « * » n'inclut JAMAIS un dossier perso, même pour l'admin.""" + from backend.auth.middleware import check_vault_access + from backend.auth.user_store import create_user, get_user + from backend.user_home import ensure_user_home + + create_user("root", "Motdepasse1!", role="admin", vaults=["*"]) + _create_user("alice") + _run(ensure_user_home("alice")) + + admin = get_user("root") + assert check_vault_access("home-alice", admin) is False # pas le sien + assert check_vault_access("TestVault", admin) is True # « * » intact + + # L'octroi explicite suffit (y compris pour l'admin, cf. _grant) + _run(ensure_user_home("root")) + assert check_vault_access("home-root", get_user("root")) is True + + +class TestSearchFiltering: + def test_search_service_filters_before_pagination(self, monkeypatch): + """#194 : /api/search ne doit pas renvoyer les notes d'un autre user.""" + import backend.search as search_mod + from backend.services import search as search_service + + hits = [ + {"vault": "home-bruno", "path": "secret.md"}, + {"vault": "TestVault", "path": "note.md"}, + {"vault": "home-bruno", "path": "autre.md"}, + ] + monkeypatch.setattr( + search_mod, "search", + lambda q, vault_filter="all", tag_filter=None: list(hits), + ) + + page = search_service.search_vaults("x", limit=50, is_allowed=lambda v: v == "TestVault") + assert page["total"] == 1, "total = résultats filtrés" + assert [r["vault"] for r in page["results"]] == ["TestVault"] + + # Sans filtre (tool layer, qui filtre lui-même) : inchangé. + assert search_service.search_vaults("x", limit=50)["total"] == 3 + class TestEnsureAll: def test_repairs_every_existing_user(self, home_env):