Scope workspace roots per account

This commit is contained in:
Hamza Ayed
2026-10-03 01:44:00 +03:00
parent 1b56f70aa6
commit 4345e43e29
10 changed files with 250 additions and 54 deletions
+19 -6
View File
@@ -236,10 +236,15 @@ class WorkspaceAgentRequest(AgentRequest):
@app.post("/v1/agent/workspace/files", dependencies=[Depends(get_authenticated_user_id)])
async def list_workspace_files(request: WorkspaceFilesRequest) -> dict[str, Any]:
async def list_workspace_files(
request: WorkspaceFilesRequest,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
"""Return bounded relative file names from the folder chosen by the desktop user."""
try:
root = workspace.selected_root(request.workspace_path)
root = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if root is None:
@@ -592,7 +597,9 @@ async def index_workspace_knowledge(
) -> dict[str, Any]:
"""Index selected files and optionally add local semantic vectors."""
try:
root = workspace.selected_root(request.workspace_path)
root = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if root is None:
@@ -721,7 +728,9 @@ def delete_workspace_knowledge(
) -> dict[str, Any]:
"""Delete selected files from this user's local knowledge index."""
try:
root = workspace.selected_root(request.workspace_path)
root = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if root is None:
@@ -747,7 +756,9 @@ async def search_workspace_knowledge(
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
try:
root = workspace.selected_root(request.workspace_path)
root = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if root is None:
@@ -1453,7 +1464,9 @@ async def _execute_agent(
await report("يتحقق من المهمة ومساحة العمل المحددة.")
try:
selected_workspace = workspace.selected_root(request.workspace_path)
selected_workspace = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
selected_skill = skills.get_skill(request.skill_id)
+67 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os
import re
import json
import difflib
import hashlib
import tempfile
@@ -29,6 +30,10 @@ MAX_PENDING_PROPOSALS = 32
_pending_changes: dict[str, dict[str, object]] = {}
class WorkspaceAccessDenied(ValueError):
"""Raised when an authenticated account selects another user's workspace."""
def configured_roots() -> tuple[Path, ...]:
"""Return the server administrator's allow-listed workspace roots."""
configured = os.getenv("SOVEREIGNAI_ALLOWED_WORKSPACES")
@@ -55,10 +60,66 @@ def configured_root() -> Path | None:
return roots[0] if roots else None
def selected_root(value: str | None) -> Path | None:
"""Resolve an explicitly selected directory, falling back to server config."""
def _roots_for_user(user_id: str | None) -> tuple[Path, ...]:
roots = configured_roots()
from app import auth, database
if user_id is None or user_id == database.LOCAL_USER_ID:
return roots
email = auth.account_email(user_id)
if email is None:
raise ValueError("الحساب الحالي لا يملك مساحة عمل مخصصة على الخادم.")
raw_mapping = os.getenv("SOVEREIGNAI_USER_WORKSPACES", "")
try:
mapping = json.loads(raw_mapping) if raw_mapping else {}
except json.JSONDecodeError as exc:
raise ValueError("إعداد مساحات العمل حسب الحساب غير صالح على الخادم.") from exc
if not isinstance(mapping, dict):
raise ValueError("إعداد مساحات العمل حسب الحساب يجب أن يكون كائن JSON.")
resolved_by_email: dict[str, tuple[Path, ...]] = {}
for assigned_email, assigned_values in mapping.items():
if not isinstance(assigned_email, str) or not isinstance(assigned_values, list):
raise ValueError("كل مستخدم في إعداد مساحات العمل يجب أن يقابله مصفوفة مسارات.")
assigned_email = assigned_email.strip().casefold()
if not assigned_email or assigned_email in resolved_by_email:
raise ValueError("البريد مكرر أو فارغ في إعداد مساحات العمل.")
assigned_roots: list[Path] = []
for item in assigned_values:
if not isinstance(item, str) or not item.strip():
raise ValueError("مسار مساحة العمل في إعداد الخادم غير صالح.")
try:
root = Path(item).expanduser().resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError("أحد جذور مساحة العمل المخصصة غير موجود أو غير متاح.") from exc
if not root.is_dir() or not any(root == allowed or allowed in root.parents for allowed in roots):
raise ValueError("جذر الحساب يجب أن يقع داخل قائمة جذور الخادم المصرح بها.")
if root in assigned_roots:
raise ValueError("جذر مساحة العمل مكرر للحساب نفسه.")
assigned_roots.append(root)
resolved_by_email[assigned_email] = tuple(assigned_roots)
# Reject overlapping account roots so one account cannot read a parent or
# child directory assigned to another account.
owners = list(resolved_by_email.items())
for index, (_first_email, first_roots) in enumerate(owners):
for _second_email, second_roots in owners[index + 1 :]:
for first_root in first_roots:
for second_root in second_roots:
if first_root == second_root or first_root in second_root.parents or second_root in first_root.parents:
raise ValueError("جذور مساحة العمل لحسابين مختلفين متداخلة في إعداد الخادم.")
assigned = resolved_by_email.get(email.casefold())
if not assigned:
raise WorkspaceAccessDenied("لم يخصص مسؤول الخادم مساحة عمل لهذا الحساب.")
return assigned
def selected_root(value: str | None, *, user_id: str | None = None) -> Path | None:
"""Resolve a selected directory only within this user's server-assigned roots."""
allowed_roots = _roots_for_user(user_id)
if value is None or not value.strip():
return configured_root()
return allowed_roots[0] if allowed_roots else None
try:
root = Path(value).expanduser().resolve(strict=True)
except (OSError, RuntimeError) as exc:
@@ -69,11 +130,12 @@ def selected_root(value: str | None) -> Path | None:
raise ValueError("اختر مجلد مشروع محددًا، وليس جذر القرص.")
if root.name.startswith(".") or root.name in IGNORED_PARTS:
raise ValueError("لا يمكن استخدام مجلد مخفي أو مستثنى كمساحة عمل.")
allowed_roots = configured_roots()
if allowed_roots and not any(
root == allowed or allowed in root.parents for allowed in allowed_roots
):
raise ValueError("المساحة المحددة خارج مجلدات المشاريع المصرح بها على الخادم.")
raise WorkspaceAccessDenied("المجلد المحدد خارج مساحة العمل المخصصة لهذا الحساب.")
if not allowed_roots:
raise WorkspaceAccessDenied("لا توجد جذور مساحة عمل مخصصة لهذا الحساب على الخادم.")
return root