"""Plugin routes — list, install, activate, deactivate, uninstall, manifest schema, config, upload, install-url.""" from __future__ import annotations import logging from typing import Any from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user, require_admin, require_permission from app.plugins.migration_runner import MigrationValidationError from app.services.plugin_service import get_plugin_service logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"]) class PluginConfigUpdate(BaseModel): """Request body for updating plugin configuration.""" config: dict class PluginUrlInstall(BaseModel): """Request body for installing a plugin from a URL.""" url: str MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB @router.get("") async def list_plugins( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("plugins:read")), ): """List all plugins with their current status (discovered, installed, active, inactive).""" service = get_plugin_service() plugins = await service.list_plugins(db) return {"plugins": plugins, "total": len(plugins)} @router.get("/manifest") async def get_manifest_schema( current_user: dict = Depends(require_permission("plugins:read")), ): """Get the plugin manifest schema documentation.""" service = get_plugin_service() return service.get_manifest_schema() @router.get("/updates") async def check_plugin_updates( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("plugins:read")), ): """Check for available plugin updates. Compares installed plugin versions with discovered plugin versions. Returns a list of plugins where the discovered version is newer. """ from app.plugins.semver import SemVer service = get_plugin_service() plugins = await service.list_plugins(db) updates: list[dict[str, Any]] = [] for plugin in plugins: if not plugin.get("installed"): continue installed_version = plugin.get("version", "0.0.0") # The discovered version is always the manifest version discovered_version = plugin.get("version", "0.0.0") # In a real marketplace scenario, we'd compare with a remote registry # For now, we check if the manifest version differs from the DB version # This is a placeholder for marketplace integration try: if SemVer.parse(discovered_version) > SemVer.parse(installed_version): updates.append({ "name": plugin["name"], "display_name": plugin.get("display_name", plugin["name"]), "current_version": installed_version, "available_version": discovered_version, }) except ValueError: pass # Skip if version is not valid SemVer return {"updates": updates, "total": len(updates)} @router.get("/active-manifests") async def get_active_manifests( db: AsyncSession = Depends(get_db), # ARCH-003 fix: every authenticated user needs the UI manifests for the # dynamic sidebar/routes — the data is pure UI metadata; actual data # access stays protected by each endpoint's own permission. current_user: dict = Depends(get_current_user), ): """Get UI manifests for all active plugins. Returns menu_items, page_routes, detail_tabs, settings_pages, and dashboard_widgets contributed by each active plugin. Used by the frontend PluginRegistry to dynamically register routes, sidebar items, settings pages, and detail tabs. """ service = get_plugin_service() manifests = await service.get_active_manifests(db) return {"plugins": manifests, "total": len(manifests)} @router.get("/{name}/config") async def get_plugin_config( name: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("plugins:read")), ): """Get the configuration for a specific plugin. Returns the plugin's config field as a JSON object. """ service = get_plugin_service() try: config = await service.get_plugin_config(db, name) return {"name": name, "config": config} except ValueError as exc: raise HTTPException( 404, detail={"detail": str(exc), "code": "plugin_not_found"} ) from None @router.patch("/{name}/config") async def update_plugin_config( name: str, body: PluginConfigUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Update the configuration for a specific plugin. Stores the config as a JSON string in the plugin's config field. """ import uuid as uuid_mod service = get_plugin_service() try: result = await service.update_plugin_config( db, name, config=body.config, tenant_id=uuid_mod.UUID(current_user["tenant_id"]), user_id=uuid_mod.UUID(current_user["user_id"]), ) return result except ValueError as exc: raise HTTPException( 404, detail={"detail": str(exc), "code": "plugin_not_found"} ) from None @router.post("/{name}/install") async def install_plugin( name: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Install a plugin by name. Runs migrations and creates DB record. Idempotent: returns 200 if already installed. """ import uuid as uuid_mod service = get_plugin_service() try: result = await service.install_plugin( db, name, tenant_id=uuid_mod.UUID(current_user["tenant_id"]), user_id=uuid_mod.UUID(current_user["user_id"]), ) return result except ValueError as exc: if "not found" in str(exc).lower(): raise HTTPException( 404, detail={"detail": str(exc), "code": "plugin_not_found"} ) from None raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None except MigrationValidationError as exc: raise HTTPException( 422, detail={"detail": str(exc), "code": "migration_validation_error"} ) from None @router.post("/{name}/activate") async def activate_plugin( name: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Activate a plugin by name. Registers event listeners and routes. Idempotent: returns 200 if already active. """ import uuid as uuid_mod service = get_plugin_service() try: result = await service.activate_plugin( db, name, tenant_id=uuid_mod.UUID(current_user["tenant_id"]), user_id=uuid_mod.UUID(current_user["user_id"]), ) return result except ValueError as exc: if "not installed" in str(exc).lower(): raise HTTPException( 400, detail={"detail": str(exc), "code": "plugin_not_installed"} ) from None if "not found" in str(exc).lower(): raise HTTPException( 404, detail={"detail": str(exc), "code": "plugin_not_found"} ) from None raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None @router.post("/{name}/deactivate") async def deactivate_plugin( name: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Deactivate a plugin by name. Unregisters event listeners and routes. Idempotent: returns 200 if already inactive. """ import uuid as uuid_mod service = get_plugin_service() try: result = await service.deactivate_plugin( db, name, tenant_id=uuid_mod.UUID(current_user["tenant_id"]), user_id=uuid_mod.UUID(current_user["user_id"]), ) return result except ValueError as exc: if "not found" in str(exc).lower(): raise HTTPException( 404, detail={"detail": str(exc), "code": "plugin_not_found"} ) from None raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None @router.delete("/{name}") async def uninstall_plugin( name: str, remove_data: bool = Query(False), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Uninstall a plugin by name. Deactivates, drops tables, removes DB record. Idempotent: returns 200 if already uninstalled. """ import uuid as uuid_mod service = get_plugin_service() try: result = await service.uninstall_plugin( db, name, remove_data=remove_data, tenant_id=uuid_mod.UUID(current_user["tenant_id"]), user_id=uuid_mod.UUID(current_user["user_id"]), ) return result except ValueError as exc: if "not found" in str(exc).lower(): raise HTTPException( 404, detail={"detail": str(exc), "code": "plugin_not_found"} ) from None raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None @router.post("/upload") async def upload_plugin( file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Upload and install a plugin from a ZIP file. DISABLED — Plugin upload is deactivated due to security vulnerabilities (RCE via exec_module before validation). Will be re-enabled with signed plugin artifacts and sandboxed execution. """ raise HTTPException( status_code=403, detail={"detail": "Plugin upload is disabled. Use signed plugin artifacts from the allowlist.", "code": "upload_disabled"}, ) @router.post("/install-url") async def install_plugin_from_url( body: PluginUrlInstall, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Install a plugin from a URL (downloads ZIP and installs). DISABLED — URL installation is deactivated due to SSRF and RCE vulnerabilities. Will be re-enabled with signed plugin artifacts and allowlist. """ raise HTTPException( status_code=403, detail={"detail": "Plugin URL installation is disabled. Use signed plugin artifacts from the allowlist.", "code": "install_url_disabled"}, ) # ── Marketplace (Phase 5) ────────────────────────────────────────────────────── class MarketplaceInstall(BaseModel): """Request body for installing a plugin from the marketplace.""" url: str signature: str | None = None public_key: str | None = None activate: bool = False @router.post("/install-marketplace") async def install_from_marketplace( body: MarketplaceInstall, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Install a plugin from the marketplace. 1. Download ZIP from marketplace URL 2. Verify signature against allowlist (if provided) 3. Quarantine: validate manifest, check dangerous imports, validate SQL 4. Install (migrations + DB record) 5. Activate (optional) DISABLED until marketplace is live — requires allowlist entry. """ raise HTTPException( status_code=403, detail={ "detail": "Marketplace installation is not yet available. Use built-in plugins.", "code": "marketplace_not_available", }, )