"""Resolve the user-visible file system for web and agent callers.""" from __future__ import annotations from dataclasses import dataclass, field import os from pathlib import Path from typing import Any import httpx from beaver.foundation.config.schema import BeaverConfig from .user_files import ( LocalUserFileStorage, MinIOStorageConfig, MinIOUserFileStorage, USER_FILE_ROOTS, UserFileError, UserFileService, ) class UserFileConfigurationError(UserFileError): """Raised when user file storage is not configured for this backend.""" @dataclass(slots=True) class FileAuthContext: """Authenticated identity used by the personal file system boundary.""" username: str backend_id: str storage_namespace: str user_id: str | None = None scopes: tuple[str, ...] = field(default_factory=tuple) auth_source: str = "beaver-web-token" @dataclass(slots=True) class UserFileStorageStatus: configured: bool storage_mode: str roots: list[str] workspace_visible: bool = False detail: str | None = None def to_dict(self) -> dict[str, Any]: payload: dict[str, Any] = { "configured": self.configured, "storage_mode": self.storage_mode, "roots": self.roots, "workspace_visible": self.workspace_visible, } if self.detail: payload["detail"] = self.detail return payload class UserFileStorageResolver: """Build `UserFileService` from the current Beaver identity and config.""" def __init__( self, *, config: BeaverConfig, workspace: Path, auth_context: FileAuthContext, ) -> None: self.config = config self.workspace = Path(workspace) self.auth_context = auth_context async def service(self) -> UserFileService: mode = _storage_mode(self.config) if mode == "local": return UserFileService(LocalUserFileStorage(self.workspace / "user_files")) settings = await self._load_minio_settings() return UserFileService( MinIOUserFileStorage( MinIOStorageConfig( endpoint=str(settings.get("endpoint") or ""), access_key=str(settings.get("access_key") or ""), secret_key=str(settings.get("secret_key") or ""), bucket=str(settings.get("bucket") or ""), secure=bool(settings.get("secure", False)), region=_clean_optional(settings.get("region")), namespace=str(settings.get("namespace") or self.auth_context.storage_namespace), ) ) ) async def status(self) -> UserFileStorageStatus: mode = _storage_mode(self.config) if mode == "local": return UserFileStorageStatus( configured=True, storage_mode="local", roots=list(USER_FILE_ROOTS), workspace_visible=False, ) try: await self._load_minio_settings() except UserFileConfigurationError as exc: return UserFileStorageStatus( configured=False, storage_mode="object", roots=list(USER_FILE_ROOTS), workspace_visible=False, detail=str(exc), ) return UserFileStorageStatus( configured=True, storage_mode="object", roots=list(USER_FILE_ROOTS), workspace_visible=False, ) async def _load_minio_settings(self) -> dict[str, Any]: backend_id = self.auth_context.backend_id.strip() if not backend_id: raise UserFileConfigurationError("User file storage backend identity is not configured") base_url = self.config.authz.base_url.strip() if not (self.config.authz.enabled and base_url): raise UserFileConfigurationError("AuthZ is required for deployed user file storage") token = ( os.getenv("BEAVER_AUTHZ_INTERNAL_TOKEN", "").strip() or os.getenv("AUTHZ_INTERNAL_TOKEN", "").strip() ) if not token: raise UserFileConfigurationError("AuthZ internal token is not configured for user file storage") try: async with httpx.AsyncClient( timeout=self.config.authz.request_timeout_seconds, follow_redirects=True, trust_env=False, ) as client: response = await client.get( f"{base_url.rstrip('/')}/internal/backends/{backend_id}/settings/minio", headers={"Authorization": f"Bearer {token}"}, ) except httpx.HTTPError as exc: raise UserFileConfigurationError(f"Unable to load user file storage settings: {exc}") from exc if response.status_code == 404: raise UserFileConfigurationError("MinIO user file storage is not configured") if response.is_error: raise UserFileConfigurationError( f"Unable to load user file storage settings: HTTP {response.status_code}" ) payload = response.json() if not isinstance(payload, dict): raise UserFileConfigurationError("Invalid MinIO settings response") if not all(str(payload.get(key) or "").strip() for key in ("endpoint", "access_key", "secret_key", "bucket")): raise UserFileConfigurationError("MinIO user file storage settings are incomplete") payload.setdefault("namespace", self.auth_context.storage_namespace) return payload def build_file_auth_context( *, username: str, config: BeaverConfig, user_id: str | None = None, scopes: tuple[str, ...] = (), auth_source: str = "beaver-web-token", ) -> FileAuthContext: backend_id = ( config.backend_identity.backend_id.strip() or os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID", "").strip() or username.strip() ) namespace = default_user_file_namespace(backend_id) return FileAuthContext( username=username.strip(), backend_id=backend_id, storage_namespace=namespace, user_id=user_id, scopes=scopes, auth_source=auth_source, ) def default_user_file_namespace(backend_id: str) -> str: cleaned = backend_id.strip().strip("/") return f"users/{cleaned}" if cleaned else "users/unconfigured" def _storage_mode(config: BeaverConfig) -> str: raw = os.getenv("BEAVER_USER_FILES_STORAGE_MODE", "").strip().lower() if raw in {"local", "dev-local", "development"}: return "local" if raw in {"minio", "object", "object-storage"}: return "minio" if config.authz.enabled and config.authz.base_url.strip() and config.backend_identity.backend_id.strip(): return "minio" return "local" def _clean_optional(value: Any) -> str | None: text = str(value or "").strip() return text or None