Files
leocrm/app/routes/plugins.py
T
Agent Zero 4a25ac1379
Check Cross-Plugin Imports / check (push) Has been cancelled
fix(arch): externes Audit — 13 Backend-Fixes (Workspace-Modules, Tenant-Manifeste, Lifecycle, Contracts, Permissions)
Verifikation: Alle 17 Audit-Findings gegen den Code geprueft — alle bestaetigt.
Backend-Lifecycle-Fixes umgesetzt; 4 Frontend-Plugin-Architektur-Punkte
als Phase Q in die Roadmap eingeplant.

- P1 list_workspaces: Module + User-Counts gebuendelt laden (Editor-Overwrite-Bug)
- P1 active-manifests: Tenant-Deaktivierung (tenant_plugin_activation) filtern
- P1 uninstall: volle Service-Deactivation VOR registry.uninstall()
- P1 ContractRegistry: DB-Aktivstatus-Guard (Restart-Edge-Case) + Re-Activate
- P1/P2 Field-Definitions: voller Lifecycle (register/unregister) im Service
- P1/P2 Contact-Felddefinitionen (39) ins ContactsPlugin-Manifest verschoben
- P1 12 fehlende Permission-Keys registriert (AST-Scan: 0 fehlend)
- P2 contact_folder -> ContactsPlugin; ENTITY_PLUGIN_OWNERS wird befuellt
- P2 Entity-Permission-Fallback fail-closed statt contacts:read
- P2 forgejo_error_reporter is_core=False; DMS is_core=True (ADR-020)
- P2 Worker: Contacts-Trash-Cleanup ins Plugin (get_job_modules-Discovery)
- P1/P2 DSGVO-Export delegiert an DSAR-Collector (kein Core->Contacts)
- P2 False-green Tests korrigiert (or True, veraltete Route-Count-Assertion)

Verifikation: tests/test_audit_architecture_fixes.py 17/17; Regressionen
gruen (contacts_lifecycle, entity_registry, workspace_scopes, rbac,
lifecycle_service); Combo-Order-Test 35/35; Cross-Plugin-Checker 497/0;
compileall sauber; ruff auf 7-Error-Baseline.

Doku: PROGRESS.md Audit-Section, PLATFORM_ROADMAP.md Phase Q (Q1-Q4),
plugin-development-guide.md Lifecycle, permissions.md Katalog.
2026-09-13 02:25:01 +02:00

413 lines
14 KiB
Python

"""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",
},
)