feat: dossier personnel par utilisateur — vault home-<user> + registre de vaults persistant #194
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s

- backend/user_home.py : ensure_user_home()/release_user_home() idempotents
  (mkdir, vault, watcher, octroi dans user.vaults), rappelés à la création
  d'un compte et au démarrage (migration + réparation + sweep des vaults
  home-* orphelins), dossier conservé à la suppression.
- backend/indexer.py : data/vaults.json, registre persistant des vaults
  ajoutés à la volée, fusionné par load_vault_config() — ils disparaissaient
  au premier reindex complet et au redémarrage. reload_single_vault remet
  vault_config[name] après remove_vault_from_index.
- backend/auth/router.py + routers/vaults.py : appels ensure/release +
  persistance à l'ajout/retrait d'un vault.
- docker-compose.yml : montage unique /NFS/OBSIDIAN_DOC/Home:/vaults/Home +
  OBSIGATE_HOME_ROOT (absente = fonctionnalité inactive).
- Tests : tests/test_user_home.py (12) + tests/test_auth_api.py::TestUserHome (2).
This commit is contained in:
2026-10-10 00:40:35 -04:00
parent f4c8504c8d
commit 8235d632b8
19 changed files with 700 additions and 17 deletions
+17 -2
View File
@@ -946,9 +946,20 @@ async def create_user_endpoint(
user = create_user(
req.username, req.password, req.role, req.vaults, req.display_name
)
return user
except ValueError as e:
raise HTTPException(400, str(e))
# #194 : dossier perso — best effort, réparé au démarrage si le disque
# (NFS) est indisponible (ensure_user_home journalise l'erreur).
from backend.auth.user_store import get_user
from backend.user_home import ensure_user_home
await ensure_user_home(req.username)
# La réponse doit refléter l'octroi du vault perso (#194) : create_user a
# renvoyé un instantané construit avant l'octroi.
fresh = get_user(req.username)
if fresh:
user["vaults"] = fresh.get("vaults", [])
return user
@router.patch("/admin/users/{username}")
@@ -975,9 +986,13 @@ async def delete_user_endpoint(
raise HTTPException(400, "Impossible de supprimer son propre compte")
try:
delete_user(username)
return {"message": f"Utilisateur '{username}' supprimé"}
except ValueError as e:
raise HTTPException(404, str(e))
# #194 : fermer le vault du dossier perso (le dossier est conservé).
from backend.user_home import release_user_home
await release_user_home(username)
return {"message": f"Utilisateur '{username}' supprimé"}
# ── API / MCP tokens (feature #107) ──────────────────────────────────
+68 -1
View File
@@ -37,6 +37,12 @@ _last_full_index_ts: str = ""
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
_on_index_change: Callable[..., None] | None = None
# Registre des vaults ajoutés à la volée (#194) : les env VAULT_N_*/DIR_N_*
# ne couvrent que le déploiement, tout ce qui est créé à runtime
# (/api/vaults/add, dossiers perso) vivrait uniquement en mémoire sinon et
# disparaîtrait au prochain rebuild ou redémarrage.
DYNAMIC_VAULTS_FILE = Path("data/vaults.json")
def set_index_change_hook(hook):
"""Register a callback for incremental inverted index updates.
@@ -134,7 +140,64 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
}
n += 1
return vaults
# Registre dynamique (#194) : les vaults créés à runtime (dossiers
# persos, /api/vaults/add) sont chargés en premier, les env gagnent en
# cas de collision de nom (vérité du déploiement).
merged = _load_dynamic_vaults()
merged.update(vaults)
return merged
def _load_dynamic_vaults() -> dict[str, dict[str, Any]]:
"""Read vaults registered at runtime from ``data/vaults.json`` (#194)."""
if not DYNAMIC_VAULTS_FILE.exists():
return {}
try:
data = json.loads(DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
vaults = data.get("vaults", {})
return vaults if isinstance(vaults, dict) else {}
except (json.JSONDecodeError, OSError) as e:
logger.error(f"Failed to read {DYNAMIC_VAULTS_FILE}: {e}")
return {}
def _save_dynamic_vaults(vaults: dict[str, dict[str, Any]]) -> None:
"""Atomic write of the dynamic vault registry (tmp + rename, as users.json)."""
try:
DYNAMIC_VAULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = DYNAMIC_VAULTS_FILE.with_suffix(".tmp")
tmp.write_text(
json.dumps({"version": 1, "vaults": vaults}, indent=2, default=str),
encoding="utf-8",
)
os.replace(tmp, DYNAMIC_VAULTS_FILE)
except OSError as e:
# Le registre est un filet : vault_config mémoire reste valable
# jusqu'au prochain rebuild, qui se reparera du dossier manquant.
logger.error(f"Failed to write {DYNAMIC_VAULTS_FILE}: {e}")
def persist_vault(vault_name: str) -> None:
"""Persist *vault_name* so it survives rebuild/restart (#194).
Idempotent. Env-declared vaults are re-added by :func:`load_vault_config`
anyway; persisting them too is harmless (single source after merge).
"""
cfg = vault_config.get(vault_name)
if not cfg:
return
vaults = _load_dynamic_vaults()
vaults[vault_name] = cfg
_save_dynamic_vaults(vaults)
def unpersist_vault(vault_name: str) -> None:
"""Drop *vault_name* from the dynamic registry (#194). No-op if absent."""
vaults = _load_dynamic_vaults()
if vault_name not in vaults:
return
vaults.pop(vault_name, None)
_save_dynamic_vaults(vaults)
@@ -867,6 +930,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
# Remove old vault data from index structures
await remove_vault_from_index(vault_name)
# remove_vault_from_index a poppé la config : la remettre, sinon le vault
# disparaît de vault_config jusqu'au prochain reload complet (#194 — les
# vaults dynamiques n'y reviennent que par data/vaults.json).
vault_config[vault_name] = config
# Re-add the vault with updated configuration
import functools
+6
View File
@@ -364,6 +364,12 @@ async def lifespan(app: FastAPI):
else:
logger.info("File watcher disabled by configuration.")
# #194 : dossier personnel par utilisateur — réparation/migration
# (mkdir manquant, registre perdu, user créé hors API). Après le
# watcher pour que les vaults ajoutés soient surveillés.
from backend.user_home import ensure_all_user_homes
await ensure_all_user_homes()
logger.info("Background startup complete.")
asyncio.create_task(_background_startup())
+9
View File
@@ -55,6 +55,11 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm
stats = await add_vault_to_index(name, vault_path)
# #194 : persister, sinon ce vault disparaît au prochain rebuild/redémarrage.
from backend.indexer import persist_vault
persist_vault(name)
# Start watching the new vault
watcher = get_watcher()
if watcher:
@@ -80,6 +85,10 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin))
await watcher.remove_vault(vault_name)
await remove_vault_from_index(vault_name)
# #194 : plus de trace au redémarrage (les vaults d'env, eux, reviennent).
from backend.indexer import unpersist_vault
unpersist_vault(vault_name)
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
return {"status": "ok", "vault": vault_name}
+165
View File
@@ -0,0 +1,165 @@
"""Dossier personnel par utilisateur (#194).
Chaque utilisateur reçoit ``<OBSIGATE_HOME_ROOT>/<username>`` monté comme un
vault propre ``home-<username>`` : l'isolation profite de l'ACL par vault
déjà en place (``check_vault_access``), aucune ACL par chemin à inventer.
Tout est idempotent (``ensure_user_home``) pour être rappelé à la création
d'un compte ET au démarrage : un dossier supprimé, un registre perdu ou un
user créé hors API se réparent au boot.
La fonctionnalité est inactive tant que ``OBSIGATE_HOME_ROOT`` n'est pas
défini (dev, tests, desktop) — comportement inchangé.
"""
from __future__ import annotations
import logging
import os
import re
from pathlib import Path
logger = logging.getLogger("obsigate.user_home")
# Même grammaire que CreateUserRequest.username_valid (backend/auth/router.py) :
# segment de chemin sûr, déjà passé par la validation de l'API. Re-vérifié ici
# car le nom sert à construire un chemin (défense en profondeur).
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,32}$")
def home_root() -> Path | None:
"""Racine des dossiers persos, ou ``None`` si la fonctionnalité est inactive."""
root = os.environ.get("OBSIGATE_HOME_ROOT", "").strip()
return Path(root) if root else None
def home_vault_name(username: str) -> str:
"""Nom de vault du dossier perso — pas de ``/`` ni ``::`` (segment d'URL, clé d'index)."""
return f"home-{username}"
async def ensure_user_home(username: str) -> str | None:
"""Crée (si besoin) le dossier perso de *username*, son vault et son octroi.
Retourne le nom de vault, ou ``None`` si désactivé / nom invalide /
erreur disque (journalisée, réparée au prochain démarrage).
"""
root = home_root()
if root is None:
return None
if not _USERNAME_RE.match(username):
logger.warning(f"Home folder skipped: invalid username {username!r}")
return None
home = root / username
try:
home.mkdir(parents=True, exist_ok=True)
except OSError:
logger.exception(f"Cannot create home folder {home} for user '{username}'")
return None
name = home_vault_name(username)
try:
from backend.indexer import add_vault_to_index, index, persist_vault, vault_config
from backend.sse import sse_manager
vault_path = str(home)
if name not in index:
await add_vault_to_index(name, vault_path)
persist_vault(name)
from backend.watcher_state import get_watcher
watcher = get_watcher()
if watcher:
await watcher.add_vault(name, vault_path)
await sse_manager.broadcast("vault_added", {"vault": name})
logger.info(f"Home vault '{name}' registered at {vault_path}")
elif name not in vault_config:
vault_config[name] = {"path": vault_path, "attachmentsPath": None,
"scanAttachmentsOnStartup": True}
except Exception:
logger.exception(f"Cannot register home vault '{name}' for user '{username}'")
return None
_grant(username, name)
return name
async def release_user_home(username: str) -> None:
"""Retire le vault du dossier perso à la suppression du compte (#194).
Le dossier sur disque est **conservé** (décision produit : pas de perte
de données) ; seul l'index, le watcher et le registre le referment.
"""
name = home_vault_name(username)
try:
from backend.indexer import index, remove_vault_from_index, unpersist_vault
unpersist_vault(name)
if name not in index:
return
await remove_vault_from_index(name)
from backend.sse import sse_manager
from backend.watcher_state import get_watcher
watcher = get_watcher()
if watcher:
await watcher.remove_vault(name)
await sse_manager.broadcast("vault_removed", {"vault": name})
logger.info(f"Home vault '{name}' released (folder kept)")
except Exception:
logger.exception(f"Cannot release home vault '{name}'")
def _grant(username: str, vault_name: str) -> None:
"""Ajoute le vault à ``user.vaults`` si l'utilisateur n'a pas déjà ``*``."""
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:
return
update_user(username, {"vaults": [*vaults, vault_name]})
async def ensure_all_user_homes() -> int:
"""Passe de réparation/migration au démarrage : un home par user existant.
Balaye aussi les vaults orphelins (compte supprimé hors route, ex.
``create_admin.py delete``) : ils sont refermés, dossier conservé.
"""
from backend.auth.user_store import get_all_users
from backend.indexer import index, vault_config
root = home_root()
if root is None:
return 0
users = get_all_users()
usernames = {u.get("username") for u in users}
created = 0
for user in users:
username = user.get("username")
if not username:
continue
if await ensure_user_home(username):
created += 1
if created:
logger.info(f"User home folders ensured for {created} user(s)")
# Orphelins : vault home-<x> toujours indexé mais <x> n'existe plus.
# Path.parent == root → on ne touche qu'aux dossiers sous la racine Home,
# jamais à un vault admin nommé « home-… » par ailleurs.
for name in list(index):
if not name.startswith("home-"):
continue
owner = name[len("home-"):]
if owner in usernames:
continue
cfg_path = Path((vault_config.get(name) or {}).get("path", ""))
if cfg_path.parent != root:
continue
logger.warning(f"Orphan home vault '{name}' released (user deleted?)")
await release_user_home(owner)
return created