4a25ac1379
Check Cross-Plugin Imports / check (push) Has been cancelled
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.
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""Plugin service layer — business logic for plugin lifecycle operations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.audit import log_audit
|
|
from app.plugins.migration_runner import MigrationValidationError
|
|
from app.plugins.registry import PluginRegistry, get_registry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PluginService:
|
|
"""Service layer for plugin lifecycle management.
|
|
|
|
Wraps the PluginRegistry with audit logging and error handling.
|
|
"""
|
|
|
|
def __init__(self, registry: PluginRegistry | None = None) -> None:
|
|
self._registry = registry or get_registry()
|
|
|
|
@property
|
|
def registry(self) -> PluginRegistry:
|
|
return self._registry
|
|
|
|
async def list_plugins(self, db: AsyncSession) -> list[dict[str, Any]]:
|
|
"""List all discovered and installed plugins with their status."""
|
|
return await self._registry.list_plugins(db)
|
|
|
|
async def install_plugin(
|
|
self,
|
|
db: AsyncSession,
|
|
name: str,
|
|
tenant_id: uuid.UUID | None = None,
|
|
user_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Install a plugin by name.
|
|
|
|
Runs migrations, calls on_install hook, creates DB record.
|
|
Idempotent: returns existing record if already installed.
|
|
Checks that all declared dependencies are installed first.
|
|
"""
|
|
try:
|
|
record = await self._registry.install(db, name)
|
|
if tenant_id and user_id:
|
|
await log_audit(
|
|
db,
|
|
tenant_id,
|
|
user_id,
|
|
action="plugin.install",
|
|
entity_type="plugin",
|
|
entity_id=getattr(record, "id", None),
|
|
changes={"name": name, "version": record.version},
|
|
)
|
|
return {
|
|
"name": record.name,
|
|
"display_name": record.display_name,
|
|
"version": record.version,
|
|
"status": record.status,
|
|
"installed": record.installed,
|
|
"active": record.active,
|
|
"message": "Plugin installed successfully",
|
|
}
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "dependencies" in msg.lower():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=msg,
|
|
) from None
|
|
raise ValueError(msg) from None
|
|
except MigrationValidationError as exc:
|
|
raise MigrationValidationError(str(exc)) from None
|
|
|
|
async def activate_plugin(
|
|
self,
|
|
db: AsyncSession,
|
|
name: str,
|
|
tenant_id: uuid.UUID | None = None,
|
|
user_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Activate a plugin by name.
|
|
|
|
Registers event listeners and routes. Idempotent.
|
|
Checks that all declared dependencies are active first.
|
|
"""
|
|
try:
|
|
# Pre-state MUST be captured BEFORE the mutation: the record returned
|
|
# by registry.activate() always reflects the NEW state, so computing
|
|
# was_already_active afterwards yielded constant True and silently
|
|
# skipped all runtime registrations (dead code — proven by
|
|
# tests/test_plugin_lifecycle_service.py, Welle 1 / Kritikpunkt 1).
|
|
pre = await self._registry._get_plugin_record(db, name)
|
|
was_already_active = bool(pre and pre.active and pre.status == "active")
|
|
|
|
record = await self._registry.activate(db, name)
|
|
|
|
# Update permission registry at runtime so newly activated plugins
|
|
# are immediately usable without app restart (P0-10 fix).
|
|
if not was_already_active:
|
|
from app.core.permission_registry import (
|
|
get_permission_registry,
|
|
register_plugin_permissions,
|
|
)
|
|
plugin = self._registry.get_plugin(name)
|
|
if plugin and plugin.manifest.permissions:
|
|
register_plugin_permissions(name, plugin.manifest.permissions)
|
|
# Add to active set so require_active_plugin() returns True
|
|
get_permission_registry()._active_plugins.add(name)
|
|
|
|
# Register entity models for permission system (P0-3 fix)
|
|
if plugin:
|
|
from app.services.entity_permission_service import register_entity_model
|
|
for entity_type, model_class in plugin.get_entity_models().items():
|
|
register_entity_model(entity_type, model_class, plugin_name=name)
|
|
|
|
# Register field definitions for field-level permissions
|
|
# (audit: contribution type now fully lifecycle-integrated)
|
|
if plugin:
|
|
field_defs = plugin.get_field_definitions()
|
|
if field_defs:
|
|
get_permission_registry().register_field_definitions(name, field_defs)
|
|
|
|
# Contract registry: clear inactive markers so contracts of
|
|
# a re-activated plugin are served again (audit P1).
|
|
from app.plugins.builtins.contracts import get_contract_registry
|
|
get_contract_registry().mark_plugin_active(name)
|
|
|
|
if tenant_id and user_id:
|
|
await log_audit(
|
|
db,
|
|
tenant_id,
|
|
user_id,
|
|
action="plugin.activate",
|
|
entity_type="plugin",
|
|
entity_id=getattr(record, "id", None),
|
|
changes={"name": name, "was_already_active": was_already_active},
|
|
)
|
|
return {
|
|
"name": record.name,
|
|
"display_name": record.display_name,
|
|
"version": record.version,
|
|
"status": record.status,
|
|
"installed": record.installed,
|
|
"active": record.active,
|
|
"message": "Plugin is already active"
|
|
if was_already_active
|
|
else "Plugin activated successfully",
|
|
}
|
|
except ValueError as exc:
|
|
msg = str(exc)
|
|
if "dependencies" in msg.lower():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=msg,
|
|
) from None
|
|
raise ValueError(msg) from None
|
|
|
|
async def deactivate_plugin(
|
|
self,
|
|
db: AsyncSession,
|
|
name: str,
|
|
tenant_id: uuid.UUID | None = None,
|
|
user_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Deactivate a plugin by name.
|
|
|
|
Unregisters event listeners and routes. Idempotent.
|
|
"""
|
|
try:
|
|
# Pre-state MUST be captured BEFORE the mutation (same reasoning as
|
|
# activate_plugin — the returned record always reflects the NEW
|
|
# state; computing was_already_inactive afterwards yielded constant
|
|
# True and skipped permission/entity/gate deregistration entirely).
|
|
pre = await self._registry._get_plugin_record(db, name)
|
|
was_already_inactive = bool(pre and not pre.active and pre.status == "inactive")
|
|
|
|
record = await self._registry.deactivate(db, name)
|
|
|
|
# Update permission registry at runtime so deactivated plugins
|
|
# immediately stop being usable (P0-10 fix).
|
|
if not was_already_inactive:
|
|
from app.core.permission_registry import (
|
|
get_permission_registry,
|
|
unregister_plugin_permissions,
|
|
)
|
|
unregister_plugin_permissions(name)
|
|
get_permission_registry()._active_plugins.discard(name)
|
|
|
|
# Unregister entity models for permission system (P0-3 fix)
|
|
plugin = self._registry.get_plugin(name)
|
|
if plugin:
|
|
from app.services.entity_permission_service import unregister_entity_model
|
|
for entity_type in plugin.get_entity_models():
|
|
unregister_entity_model(entity_type)
|
|
|
|
# Unregister field definitions (audit: full lifecycle)
|
|
get_permission_registry().unregister_field_definitions(name)
|
|
|
|
# Contract registry: fail closed for the deactivated plugin
|
|
# (ARCH-014 / audit P1 — central, so every plugin is covered
|
|
# even if its own on_deactivate forgets the unregister).
|
|
from app.plugins.builtins.contracts import get_contract_registry
|
|
get_contract_registry().unregister(name)
|
|
|
|
if tenant_id and user_id:
|
|
await log_audit(
|
|
db,
|
|
tenant_id,
|
|
user_id,
|
|
action="plugin.deactivate",
|
|
entity_type="plugin",
|
|
entity_id=getattr(record, "id", None),
|
|
changes={"name": name, "was_already_inactive": was_already_inactive},
|
|
)
|
|
return {
|
|
"name": record.name,
|
|
"display_name": record.display_name,
|
|
"version": record.version,
|
|
"status": record.status,
|
|
"installed": record.installed,
|
|
"active": record.active,
|
|
"message": "Plugin is already inactive"
|
|
if was_already_inactive
|
|
else "Plugin deactivated successfully",
|
|
}
|
|
except ValueError as exc:
|
|
raise ValueError(str(exc)) from None
|
|
|
|
async def uninstall_plugin(
|
|
self,
|
|
db: AsyncSession,
|
|
name: str,
|
|
remove_data: bool = False,
|
|
tenant_id: uuid.UUID | None = None,
|
|
user_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Uninstall a plugin by name.
|
|
|
|
Deactivates, calls on_uninstall hook, optionally drops tables, removes DB record.
|
|
"""
|
|
try:
|
|
# Audit P1 (uninstall lifecycle): run the FULL service-level
|
|
# deactivation first. registry.uninstall()'s internal fallback
|
|
# (registry.deactivate) does NOT clean PermissionRegistry,
|
|
# _active_plugins or ENTITY_MODELS — an active plugin uninstalled
|
|
# directly through the registry left stale registrations behind.
|
|
pre = await self._registry._get_plugin_record(db, name)
|
|
if pre is not None and pre.active:
|
|
await self.deactivate_plugin(
|
|
db, name, tenant_id=tenant_id, user_id=user_id
|
|
)
|
|
record = await self._registry.uninstall(db, name, remove_data=remove_data)
|
|
dropped_tables = getattr(record, "dropped_tables", [])
|
|
if tenant_id and user_id:
|
|
await log_audit(
|
|
db,
|
|
tenant_id,
|
|
user_id,
|
|
action="plugin.uninstall",
|
|
entity_type="plugin",
|
|
changes={
|
|
"name": name,
|
|
"remove_data": remove_data,
|
|
"dropped_tables": dropped_tables,
|
|
},
|
|
)
|
|
return {
|
|
"name": name,
|
|
"display_name": record.display_name,
|
|
"version": record.version,
|
|
"status": "uninstalled",
|
|
"installed": False,
|
|
"active": False,
|
|
"dropped_tables": dropped_tables,
|
|
"message": f"Plugin uninstalled{' and data tables dropped' if remove_data else ''}",
|
|
}
|
|
except ValueError as exc:
|
|
raise ValueError(str(exc)) from None
|
|
|
|
async def get_plugin_config(self, db: AsyncSession, name: str) -> dict[str, Any]:
|
|
"""Get the configuration for a plugin.
|
|
|
|
Returns the plugin's config field (JSONB, already parsed by SQLAlchemy).
|
|
"""
|
|
record = await self._registry._get_plugin_record(db, name)
|
|
if record is None:
|
|
raise ValueError(f"Plugin '{name}' is not installed")
|
|
return getattr(record, "config", None) or {}
|
|
|
|
async def update_plugin_config(
|
|
self,
|
|
db: AsyncSession,
|
|
name: str,
|
|
config: dict[str, Any],
|
|
tenant_id: uuid.UUID | None = None,
|
|
user_id: uuid.UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Update the configuration for a plugin.
|
|
|
|
Stores the config directly as JSONB in the plugin's config field.
|
|
"""
|
|
record = await self._registry._get_plugin_record(db, name)
|
|
if record is None:
|
|
raise ValueError(f"Plugin '{name}' is not installed")
|
|
|
|
record.config = config
|
|
await db.flush()
|
|
|
|
if tenant_id and user_id:
|
|
await log_audit(
|
|
db,
|
|
tenant_id,
|
|
user_id,
|
|
action="plugin.update_config",
|
|
entity_type="plugin",
|
|
entity_id=getattr(record, "id", None),
|
|
changes={"name": name, "config": config},
|
|
)
|
|
|
|
return {
|
|
"name": name,
|
|
"config": config,
|
|
"message": "Plugin configuration updated successfully",
|
|
}
|
|
|
|
def get_manifest_schema(self) -> dict[str, Any]:
|
|
"""Return the manifest schema documentation."""
|
|
from app.plugins.manifest import MANIFEST_SCHEMA_DOC
|
|
|
|
return MANIFEST_SCHEMA_DOC.model_dump()
|
|
|
|
async def get_active_manifests(
|
|
self, db: AsyncSession, tenant_id: uuid.UUID | None = None
|
|
) -> list[dict[str, Any]]:
|
|
"""Return UI manifests for all active plugins.
|
|
|
|
Audit P1 (tenant manifests): *tenant_id* filters out plugins that are
|
|
deactivated for the caller's tenant (tenant_plugin_activation),
|
|
mirroring require_active_plugin() so UI and backend agree.
|
|
"""
|
|
return await self._registry.get_active_manifests(db, tenant_id=tenant_id)
|
|
|
|
|
|
# Global service instance
|
|
_service: PluginService | None = None
|
|
|
|
|
|
def get_plugin_service() -> PluginService:
|
|
"""Get the global plugin service instance."""
|
|
global _service
|
|
if _service is None:
|
|
_service = PluginService()
|
|
return _service
|
|
|
|
|
|
def reset_plugin_service_for_testing(registry: PluginRegistry | None = None) -> PluginService:
|
|
"""Create a fresh plugin service for testing."""
|
|
global _service
|
|
_service = PluginService(registry=registry)
|
|
return _service
|