202 lines
6.9 KiB
Python
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
|