"""Sandbox Gateway endpoints. Note what is **absent**: there is no route that accepts a shell command and runs it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a policy timeout and output cap, and is metered into the usage ledger. **Who may call what (v2).** Containers, ports and command output are *infrastructure*, and v2 gives infrastructure to the platform owner alone (PLAN-v2 §7.7): a customer only ever gives orders to the AI. So every mutating route here — create/start/stop a workspace or session, run a command, read or write a file, snapshot, mint a preview URL, set limits — requires the platform owner. The read-only status routes (templates, effective limits, workspace list, the command log) stay open to org members, because the project page shows them. The org-scoped prefix is kept even for owner-only routes: it is what scopes the audit trail and the tenancy check, and it means the platform owner must *deliberately* be a member of the org they are operating on. """ from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Depends, Query, Request, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.application.services.sandbox_service import SandboxService from app.core.database import get_session from app.core.errors import ConflictError, PermissionDeniedError, ValidationError from app.domain.audit.models import AuditAction from app.domain.identity.models import User from app.domain.sandbox.models import NetworkPolicy from app.infrastructure.adapters.docker_sandbox import LANGUAGE_IMAGES from app.infrastructure.db.models import SandboxResourceLimitRow from app.interfaces.http.deps import ( OrgContext, client_meta, get_org_context, get_sandbox_service, require_platform_owner, ) router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"]) #: The platform owner's view of every container on the host. Mounted at #: ``/api/admin/sandbox`` — a *separate* router object, because the tenant router #: above is org-scoped and this one must never be. admin_router = APIRouter(prefix="/admin/sandbox", tags=["admin-sandbox"]) def _require_platform_owner(context: OrgContext) -> None: """Infrastructure belongs to the platform owner (PLAN-v2 §7.7). Deliberately *not* an org-capability check: an org admin running a command in a container is exactly the "users touch infrastructure" behaviour v2 removes. """ if not context.user.is_platform_owner: raise PermissionDeniedError( "دسترسی به زیرساخت فقط برای مالک سامانه امکان‌پذیر است." ) # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class SandboxTemplateOut(BaseModel): id: str name: str provider: str image: str description: str default_cpu: float default_memory_mb: int default_disk_mb: int class WorkspaceOut(BaseModel): id: str project_id: str template_id: str provider: str status: str created_at: datetime class CreateWorkspaceRequest(BaseModel): project_id: str template_id: str class SessionOut(BaseModel): id: str workspace_id: str status: str started_at: datetime last_active_at: datetime class StartSessionRequest(BaseModel): workspace_id: str class CommandRunOut(BaseModel): id: str command: str exit_code: int | None stdout: str stderr: str duration_ms: int truncated: bool created_at: datetime class RunCommandRequest(BaseModel): command: str = Field(min_length=1, max_length=4_000) class FileReadOut(BaseModel): path: str content: str class ReadFileRequest(BaseModel): path: str = Field(min_length=1, max_length=1_000) class WriteFileRequest(BaseModel): path: str = Field(min_length=1, max_length=1_000) content: str = Field(max_length=1_000_000) class SnapshotOut(BaseModel): id: str name: str created_at: datetime class CreateSnapshotRequest(BaseModel): name: str = Field(min_length=1, max_length=120) class PreviewUrlOut(BaseModel): port: int url: str class ResourceLimitOut(BaseModel): cpu: float memory_mb: int disk_mb: int timeout_seconds: int max_output_bytes: int network_policy: str class SetLimitsRequest(BaseModel): scope: str = "organization" scope_id: str | None = None cpu: float = Field(default=1.0, gt=0, le=16) memory_mb: int = Field(default=1024, ge=128, le=65_536) disk_mb: int = Field(default=2048, ge=256, le=1_048_576) timeout_seconds: int = Field(default=120, ge=1, le=900) max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000) network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL # --------------------------------------------------------------------------- # Templates & limits # --------------------------------------------------------------------------- @router.get("/templates", response_model=list[SandboxTemplateOut]) async def list_templates( context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> list[SandboxTemplateOut]: templates = await service.list_templates() return [ SandboxTemplateOut( id=t.id, name=t.name, provider=str(t.provider), image=t.image, description=t.description, default_cpu=t.default_cpu, default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb, ) for t in templates ] @router.get("/limits", response_model=ResourceLimitOut) async def get_limits( project_id: str | None = Query(default=None), context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> ResourceLimitOut: limit = await service.effective_limits( organization_id=context.org_id, project_id=project_id ) return ResourceLimitOut( cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb, timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes, network_policy=str(limit.network_policy), ) @router.put("/limits", response_model=ResourceLimitOut) async def set_limits( payload: SetLimitsRequest, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> ResourceLimitOut: _require_platform_owner(context) limit = await service.set_limits( scope=payload.scope, scope_id=payload.scope_id or context.org_id, cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb, timeout_seconds=payload.timeout_seconds, max_output_bytes=payload.max_output_bytes, network_policy=payload.network_policy, ) return ResourceLimitOut( cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb, timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes, network_policy=str(limit.network_policy), ) # --------------------------------------------------------------------------- # Workspaces # --------------------------------------------------------------------------- @router.post( "/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED ) async def create_workspace( payload: CreateWorkspaceRequest, request: Request, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), session: AsyncSession = Depends(get_session), ) -> WorkspaceOut: _require_platform_owner(context) workspace = await service.create_workspace( organization_id=context.org_id, project_id=payload.project_id, template_id=payload.template_id, ) ip, ua = client_meta(request) await record_audit( session, action="sandbox.workspace_create", organization_id=context.org_id, actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id, metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)}, ip=ip, user_agent=ua, ) return WorkspaceOut( id=workspace.id, project_id=workspace.project_id, template_id=workspace.template_id, provider=str(workspace.provider), status=str(workspace.status), created_at=workspace.created_at, ) @router.get("/workspaces", response_model=list[WorkspaceOut]) async def list_workspaces( project_id: str = Query(min_length=1), context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), ) -> list[WorkspaceOut]: workspaces = await service.list_workspaces( organization_id=context.org_id, project_id=project_id, limit=limit, offset=offset, ) return [ WorkspaceOut( id=w.id, project_id=w.project_id, template_id=w.template_id, provider=str(w.provider), status=str(w.status), created_at=w.created_at, ) for w in workspaces ] @router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut) async def destroy_workspace( workspace_id: str, request: Request, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), session: AsyncSession = Depends(get_session), ) -> WorkspaceOut: _require_platform_owner(context) workspace = await service.destroy_workspace( organization_id=context.org_id, workspace_id=workspace_id ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id, actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id, ip=ip, user_agent=ua, ) return WorkspaceOut( id=workspace.id, project_id=workspace.project_id, template_id=workspace.template_id, provider=str(workspace.provider), status=str(workspace.status), created_at=workspace.created_at, ) # --------------------------------------------------------------------------- # Sessions # --------------------------------------------------------------------------- @router.post( "/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED ) async def start_session( payload: StartSessionRequest, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> SessionOut: _require_platform_owner(context) sandbox_session = await service.start_session( organization_id=context.org_id, workspace_id=payload.workspace_id ) return SessionOut( id=sandbox_session.id, workspace_id=sandbox_session.workspace_id, status=str(sandbox_session.status), started_at=sandbox_session.started_at, last_active_at=sandbox_session.last_active_at, ) @router.post("/sessions/{session_id}/stop", response_model=SessionOut) async def stop_session( session_id: str, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> SessionOut: _require_platform_owner(context) sandbox_session = await service.stop_session( organization_id=context.org_id, session_id=session_id ) return SessionOut( id=sandbox_session.id, workspace_id=sandbox_session.workspace_id, status=str(sandbox_session.status), started_at=sandbox_session.started_at, last_active_at=sandbox_session.last_active_at, ) # --------------------------------------------------------------------------- # Commands & files # --------------------------------------------------------------------------- @router.post("/sessions/{session_id}/commands", response_model=CommandRunOut) async def run_command( session_id: str, payload: RunCommandRequest, request: Request, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), session: AsyncSession = Depends(get_session), ) -> CommandRunOut: _require_platform_owner(context) run, _result = await service.run_command( organization_id=context.org_id, session_id=session_id, command=payload.command, user_id=context.user.id, ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id, actor_user_id=context.user.id, target_type="sandbox_session", target_id=session_id, # The command itself is recorded, but never a secret from the environment. metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms}, ip=ip, user_agent=ua, ) return CommandRunOut( id=run.id, command=run.command, exit_code=run.exit_code, stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms, truncated=run.truncated, created_at=run.created_at, ) @router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut]) async def list_commands( session_id: str, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> list[CommandRunOut]: await service.get_session_or_404( organization_id=context.org_id, session_id=session_id ) runs = await service.list_commands(session_id=session_id) return [ CommandRunOut( id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref, stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated, created_at=r.created_at, ) for r in runs ] @router.post("/sessions/{session_id}/files/read", response_model=FileReadOut) async def read_file( session_id: str, payload: ReadFileRequest, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> FileReadOut: _require_platform_owner(context) content = await service.read_file( organization_id=context.org_id, session_id=session_id, path=payload.path ) return FileReadOut(path=payload.path, content=content) @router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT) async def write_file( session_id: str, payload: WriteFileRequest, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> None: _require_platform_owner(context) await service.write_file( organization_id=context.org_id, session_id=session_id, path=payload.path, content=payload.content, ) # --------------------------------------------------------------------------- # Snapshots & preview # --------------------------------------------------------------------------- @router.post( "/sessions/{session_id}/snapshots", response_model=SnapshotOut, status_code=status.HTTP_201_CREATED, ) async def create_snapshot( session_id: str, payload: CreateSnapshotRequest, context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> SnapshotOut: _require_platform_owner(context) snapshot = await service.snapshot( organization_id=context.org_id, session_id=session_id, name=payload.name ) return SnapshotOut( id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at ) @router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut) async def get_preview_url( session_id: str, port: int = Query(ge=1, le=65535), context: OrgContext = Depends(get_org_context), service: SandboxService = Depends(get_sandbox_service), ) -> PreviewUrlOut: _require_platform_owner(context) preview = await service.get_preview_url( organization_id=context.org_id, session_id=session_id, port=port ) return PreviewUrlOut(port=preview.port, url=preview.url) # --------------------------------------------------------------------------- # Platform-owner sandbox console (/admin/sandbox) # --------------------------------------------------------------------------- class ContainerRow(BaseModel): id: str name: str status: str organization_id: str project_id: str role: str class SandboxListOut(BaseModel): containers: list[ContainerRow] limits: list[SandboxLimitRow] images: dict[str, str] class SandboxLimitRow(BaseModel): organization_id: str cpu: float memory_mb: int disk_mb: int timeout_seconds: int network_policy: str class StopContainerRequest(BaseModel): #: The container id, typed back — stopping one is destructive. confirm: str = Field(min_length=1, max_length=200) @admin_router.get("/containers", response_model=SandboxListOut) async def list_containers( owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> SandboxListOut: """Every container carrying our labels, plus the per-org limits. A provider that cannot list containers (the mock, a remote adapter without the capability) returns an empty list rather than an error: the console is diagnostic, and a provider outage should not look like "no containers". """ from app.infrastructure.adapters.registry import get_sandbox_provider provider = get_sandbox_provider() lister = getattr(provider, "list_managed_containers", None) containers: list[ContainerRow] = [] if lister is not None: try: for item in await lister(): containers.append( ContainerRow( id=item.container_id, name=item.name, status=item.status, organization_id=item.organization_id, project_id=item.project_id, role=item.role, ) ) except Exception: # noqa: BLE001 - a CLI outage is not a 500 containers = [] limits = ( await session.execute( select(SandboxResourceLimitRow).where( SandboxResourceLimitRow.scope == "organization" ) ) ).scalars() return SandboxListOut( containers=containers, limits=[ SandboxLimitRow( organization_id=row.scope_id or "", cpu=row.cpu, memory_mb=row.memory_mb, disk_mb=row.disk_mb, timeout_seconds=row.timeout_seconds, network_policy=str(row.network_policy), ) for row in limits if row.scope_id ], images=dict(LANGUAGE_IMAGES), ) @admin_router.post("/containers/{container_id}/stop", status_code=status.HTTP_204_NO_CONTENT) async def stop_container( container_id: str, payload: StopContainerRequest, request: Request, owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> None: """Stop and remove a container. Owner-only, confirmed and audited.""" if payload.confirm.strip() != container_id.strip(): raise ValidationError("تأییدیه با شناسهٔ کانتینر مطابقت ندارد.") from app.infrastructure.adapters.registry import get_sandbox_provider provider = get_sandbox_provider() remover = getattr(provider, "remove_container", None) if remover is None: raise ValidationError("این ارائه‌دهندهٔ سندباکس توقف کانتینر را پشتیبانی نمی‌کند.") removed = await remover(container_id) if not removed: raise ConflictError("توقف کانتینر ناموفق بود.") ip, ua = client_meta(request) await record_audit( session, action=AuditAction.SANDBOX_DESTROY, actor_user_id=owner.id, target_type="sandbox_container", target_id=container_id, ip=ip, user_agent=ua, )