Files
beaver_project/app-instance/backend/beaver/services/user_file_resolver.py
2026-06-03 12:06:34 +08:00

202 lines
6.9 KiB
Python

"""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