"""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, status 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.plugins.registry import get_registry 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("/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. Audit P1 (tenant manifests): plugins deactivated for the caller's tenant are excluded so UI and API gates agree (no 403-on-click menus). """ import uuid as uuid_mod service = get_plugin_service() tenant_id: uuid_mod.UUID | None = None raw_tid = current_user.get("tenant_id") if raw_tid: try: tenant_id = uuid_mod.UUID(str(raw_tid)) except (ValueError, TypeError): tenant_id = None manifests = await service.get_active_manifests(db, tenant_id=tenant_id) return {"plugins": manifests, "total": len(manifests)} @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("/{name}") async def get_plugin_detail( name: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("plugins:read")), ): """Get detail for a single plugin: manifest metadata + DB status. BUG-024 fix: this endpoint was missing entirely (404). """ registry = get_registry() plugin = registry.get_plugin(name) if plugin is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": f"Plugin '{name}' not found", "code": "not_found"}, ) m = plugin.manifest record = registry.get_db_status(name) return { "name": m.name, "version": m.version, "display_name": getattr(m, "display_name", None) or m.name, "description": getattr(m, "description", None), "author": getattr(m, "author", None), "is_core": bool(getattr(m, "is_core", False)), "permissions": list(m.permissions), "depends_on": list(getattr(m, "depends_on", []) or []), "status": { "installed": record is not None, "active": bool(record.active) if record is not None else False, "version_installed": getattr(record, "version", None), }, "menu_items": len(getattr(m, "menu_items", []) or []), "page_routes": len(getattr(m, "page_routes", []) or []), "detail_tabs": len(getattr(m, "detail_tabs", []) or []), "settings_pages": len(getattr(m, "settings_pages", []) or []), "dashboard_widgets": len(getattr(m, "dashboard_widgets", []) or []), } @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", }, )