fix(security): 16 mittlere Probleme behoben (P18-P33)
Check Cross-Plugin Imports / check (push) Has been cancelled

P18: require_permission zu forgejo_error_reporter und ai_ui_control routes hinzugefügt
P19: Cross-Tenant Permission-Cache-Invalidierung bei Rollenänderungen
P20: Session/Permission-Cache-Invalidierung bei Gruppen-Änderungen
P21: ENTITY_MODELS Registry um fehlende Plugin-Modelle erweitert
P22: Entity-Links prüfen verknüpfte Entity-Permissions
P23: authStore persist Middleware entfernt (kein localStorage mehr)
P24: 5xx Retry nur noch für GET-Requests
P25: KI-Kommentar in address.py (bekannte Inkonsistenz)
P26: DeletionLog in EntityHistory gemerged (action=delete)
P27: KI-Kommentar in entity_policy.py (ABAC nicht aktiv genutzt)
P28: db.commit() aus bulk_permission_service entfernt
P29: CSV-Export in export_service.py ausgelagert
P30: plugins.py Business-Logik in plugin_install_service.py ausgelagert
P31: KI-Kommentar in session.py (Dual-System dokumentiert)
P32: Migration 0115: crm_platform_admin Role droppen
P33: Cross-Plugin Imports über contracts.py behoben (10 Violations → 0)
This commit is contained in:
Agent Zero
2026-08-06 13:23:58 +02:00
parent 9f79107fa7
commit 0eb6d7621e
32 changed files with 593 additions and 307 deletions
+13 -5
View File
@@ -7,7 +7,8 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditLog, DeletionLog
from app.models.audit import AuditLog
from app.models.entity_history import EntityHistory
async def log_audit(
@@ -40,14 +41,21 @@ async def log_deletion(
entity_type: str,
entity_id: uuid.UUID,
entity_snapshot: dict[str, Any],
) -> DeletionLog:
"""Create a deletion log entry (immutable snapshot)."""
entry = DeletionLog(
) -> EntityHistory:
"""Create a deletion history entry (merged from DeletionLog into EntityHistory).
Stores the full entity snapshot in snapshot_before for forensic recovery.
"""
entry = EntityHistory(
tenant_id=tenant_id,
user_id=user_id,
entity_type=entity_type,
entity_id=entity_id,
entity_snapshot=entity_snapshot,
action="delete",
snapshot_before=entity_snapshot,
snapshot_after=None,
changes=None,
owner_id=user_id,
)
db.add(entry)
await db.flush()
+1 -2
View File
@@ -4,7 +4,7 @@ from app.models.address import Address
from app.models.bank_account import BankAccount
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.attachment import Attachment
from app.models.audit import AuditLog, DeletionLog
from app.models.audit import AuditLog
from app.models.auth import ApiToken, PasswordResetToken
from app.models.contact import Contact, ContactPerson
from app.models.contact_folder import ContactFolder
@@ -43,7 +43,6 @@ __all__ = [
"UserGroup",
"Session",
"AuditLog",
"DeletionLog",
"Notification",
"NotificationType",
"NotificationPreference",
+5 -1
View File
@@ -1,4 +1,8 @@
"""Address model — polymorphic addresses for companies and contacts."""
"""Address model — polymorphic addresses for companies and contacts.
⚠️ Address-Tabelle wird für Bank-Accounts genutzt. Contacts nutzen inline Address-Felder.
Diese Inkonsistenz ist bekannt und wird bei Gelegenheit vereinheitlicht.
"""
from __future__ import annotations
+4 -19
View File
@@ -1,4 +1,7 @@
"""AuditLog and DeletionLog models."""
"""AuditLog model — audit trail for all create/update/delete/login actions.
Note: DeletionLog has been merged into EntityHistory (action='delete').
"""
from __future__ import annotations
@@ -33,21 +36,3 @@ class AuditLog(Base, TenantMixin):
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
)
class DeletionLog(Base, TenantMixin):
"""Immutable record of deleted entities (for forensic recovery)."""
__tablename__ = "deletion_log"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
entity_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
deleted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+3
View File
@@ -1,5 +1,8 @@
"""ABAC entity policy model — attribute-based access control policies.
⚠️ ABAC EntityPolicy ist implementiert aber wird nicht aktiv genutzt.
Bei echtem Bedarf aktivieren, sonst bei Gelegenheit entfernen.
Each policy defines a rule for a specific entity type:
- allow policies: at least one must match for access
- deny policies: if any matches, access is denied (deny takes precedence)
+5 -1
View File
@@ -1,4 +1,8 @@
"""Session model — PostgreSQL audit trail for sessions."""
"""Session model — PostgreSQL audit trail for sessions.
⚠️ Session-Tabelle dient als audit trail. Redis ist der Runtime-Session-Store.
Dies ist ein bewusstes Dual-System.
"""
from __future__ import annotations
+1 -1
View File
@@ -151,7 +151,7 @@ async def update_memory(
if "content" in data:
memory.content = data["content"]
# Regenerate embedding for updated content
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.contracts import generate_embedding
embedding = await generate_embedding(data["content"], db=db, tenant_id=tenant_id)
if embedding:
from sqlalchemy import text as sql_text
@@ -9,7 +9,7 @@ from sqlalchemy import select, text as sql_text
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.agent_memory.models import AgentMemory
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.contracts import generate_embedding
async def store_memory(
@@ -183,14 +183,14 @@ async def get_agent_status_external(
raise HTTPException(status_code=404, detail="Agent not found")
# Get recent run stats
from app.plugins.builtins.automation.models import AgentRun
from app.plugins.builtins.automation.contracts import AutomationContract
from sqlalchemy import func
recent_runs = await db.execute(
select(func.count())
.select_from(AgentRun)
.where(AgentRun.agent_id == aid)
.where(AgentRun.tenant_id == tenant_id)
.select_from(AutomationContract.AgentRun)
.where(AutomationContract.AgentRun.agent_id == aid)
.where(AutomationContract.AgentRun.tenant_id == tenant_id)
)
total_runs = recent_runs.scalar() or 0
+3 -2
View File
@@ -14,6 +14,7 @@ import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from app.deps import require_permission
from app.plugins.builtins.ai_ui_control.schemas import (
UICommand,
UICommandCreate,
@@ -31,7 +32,7 @@ router = APIRouter(prefix="/api/v1/ai-ui-control", tags=["ai-ui-control"])
# ─── REST endpoints (for AI agents) ───
@router.post("/command", response_model=UICommandResponse)
@router.post("/command", response_model=UICommandResponse, dependencies=[Depends(require_permission("ai_ui_control:write"))])
async def send_ui_command(
request: Request,
body: UICommandCreate,
@@ -168,7 +169,7 @@ async def get_command_status(
)
@router.get("/online-users")
@router.get("/online-users", dependencies=[Depends(require_permission("ai_ui_control:read"))])
async def get_online_users(request: Request):
"""Check which users are currently online (have active frontend WS connections)."""
from app.config import get_settings
@@ -263,7 +263,8 @@ class AgentCoordinator:
def register_agent_coordinator_tools():
"""Register AgentCoordinator tools in the global tool registry."""
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import AIAssistantContract
get_tool_registry = AIAssistantContract.get_tool_registry
registry = get_tool_registry()
@@ -355,7 +356,8 @@ def register_agent_coordinator_tools():
def unregister_agent_coordinator_tools():
"""Unregister AgentCoordinator tools."""
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.ai_assistant.contracts import AIAssistantContract
get_tool_registry = AIAssistantContract.get_tool_registry
registry = get_tool_registry()
registry.unregister("create_subtask")
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission
from app.services.entity_permission_service import check_entity_access
from app.plugins.builtins.entity_links.models import EntityLink
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
@@ -47,6 +48,14 @@ async def link_file_to_entity(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
# Verify user has read access to both the file and the target entity
has_file_access = await check_entity_access(db, tenant_id, user_id, "file", fid, "read")
if not has_file_access:
raise HTTPException(403, detail={"detail": "No access to file", "code": "forbidden"})
has_entity_access = await check_entity_access(db, tenant_id, user_id, body.entity_type, entity_id, "read")
if not has_entity_access:
raise HTTPException(403, detail={"detail": "No access to target entity", "code": "forbidden"})
# Check if link already exists
existing = await db.execute(
select(EntityLink).where(
@@ -4,14 +4,16 @@ from __future__ import annotations
import logging
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.deps import require_permission
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/forgejo-error-reporter", tags=["forgejo_error_reporter"])
@router.get("/status")
@router.get("/status", dependencies=[Depends(require_permission("system:read"))])
async def get_status() -> dict:
"""Get the current status of the Forgejo Error Reporter plugin."""
from app.plugins.registry import get_registry
@@ -0,0 +1,33 @@
"""GraphRAG plugin contract — public interface for cross-plugin access."""
from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.graph_rag.provider import GraphRAGSearchProvider
class GraphRagContract:
"""Public contract for the graph_rag plugin."""
contract_name = "graph_rag"
GraphRAGSearchProvider = GraphRAGSearchProvider
# ─── self-registration ───
_contract = GraphRagContract()
get_contract_registry().register("graph_rag", _contract)
# Backward-compatible local accessor
_contract_instance: GraphRagContract | None = None
def get_contract() -> GraphRagContract:
global _contract_instance
if _contract_instance is None:
_contract_instance = GraphRagContract()
return _contract_instance
__all__ = ["GraphRagContract", "GraphRAGSearchProvider"]
+2 -2
View File
@@ -37,7 +37,7 @@ class GraphRAGPlugin(BasePlugin):
async def on_activate(self, db, service_container, event_bus) -> None:
"""Activate plugin: register GraphRAG search provider."""
from app.plugins.builtins.graph_rag.provider import GraphRAGSearchProvider
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
from app.plugins.builtins.unified_search.contracts import get_search_registry
registry = get_search_registry()
try:
@@ -50,7 +50,7 @@ class GraphRAGPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Deactivate plugin: unregister search provider and contract."""
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
from app.plugins.builtins.unified_search.contracts import get_search_registry
get_search_registry().unregister("graph_relationship")
from app.plugins.builtins.contracts import get_contract_registry
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider
from app.plugins.builtins.unified_search.contracts import BaseSearchProvider
logger = logging.getLogger(__name__)
@@ -13,7 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.storage import get_storage_backend
from app.plugins.builtins.permissions.models import ShareLink
from app.plugins.builtins.dms.models import File as DmsFile
from app.plugins.builtins.dms.contracts import DmsContract
DmsFile = DmsContract.DmsFile
router = APIRouter(prefix="/api/v1/public/share", tags=["public-share"])
@@ -5,6 +5,8 @@ from __future__ import annotations
from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.unified_search.embedding import generate_embedding
from app.plugins.builtins.unified_search.search_engine import hybrid_search
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider
class UnifiedSearchContract:
@@ -14,6 +16,8 @@ class UnifiedSearchContract:
generate_embedding = staticmethod(generate_embedding)
hybrid_search = staticmethod(hybrid_search)
get_search_registry = staticmethod(get_search_registry)
BaseSearchProvider = BaseSearchProvider
# ─── self-registration ───
@@ -32,4 +36,4 @@ def get_contract() -> UnifiedSearchContract:
return _contract_instance
__all__ = ["UnifiedSearchContract", "generate_embedding", "hybrid_search"]
__all__ = ["UnifiedSearchContract", "generate_embedding", "hybrid_search", "get_search_registry", "BaseSearchProvider"]
@@ -130,9 +130,8 @@ async def auto_register_providers(db: AsyncSession) -> None:
from app.plugins.builtins.unified_search.providers.user_provider import (
UserSearchProvider,
)
from app.plugins.builtins.graph_rag.provider import (
GraphRAGSearchProvider,
)
from app.plugins.builtins.graph_rag.contracts import GraphRagContract
GraphRAGSearchProvider = GraphRagContract.GraphRAGSearchProvider
registry = get_search_registry()
registry.clear()
+1 -1
View File
@@ -100,7 +100,7 @@ async def export_contacts(
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False)
csv_data = await contact_service.export_contacts_csv(
csv_data = await export_service.export_contacts_csv(
db, tenant_id, contact_type=type, search=search,
user_id=user_id, is_system_admin=is_admin,
)
+1 -189
View File
@@ -2,27 +2,18 @@
from __future__ import annotations
import importlib
import logging
import os
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any
import httpx
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 require_permission, require_admin
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest
from app.plugins.migration_runner import MigrationValidationError
from app.services.plugin_service import get_plugin_service
from app.services.plugin_install_service import PluginInstallService
logger = logging.getLogger(__name__)
@@ -293,185 +284,6 @@ async def uninstall_plugin(
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None
# ── Plugin Upload / URL Install ──────────────────────────────────────────────
def _validate_manifest_name(name: str) -> str:
"""Validate plugin name is alphanumeric with underscores only."""
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name):
raise ValueError(
f"Invalid plugin name '{name}': must start with a letter and contain only "
f"alphanumeric characters and underscores"
)
return name
def _check_dangerous_imports(source_code: str) -> list[str]:
"""Check plugin source for dangerous imports/patterns.
Returns a list of dangerous patterns found (empty if safe).
"""
dangerous_patterns = [
(r"\bos\.system\b", "os.system call"),
(r"\bsubprocess\.", "subprocess module"),
(r"\beval\s*\(", "eval() call"),
(r"\bexec\s*\(", "exec() call"),
(r"\b__import__\s*\(", "__import__() call"),
(r"\bcompile\s*\(", "compile() call"),
]
found: list[str] = []
for pattern, description in dangerous_patterns:
if re.search(pattern, source_code):
found.append(description)
return found
def _check_migration_sql(sql_content: str) -> list[str]:
"""Basic SQL validation for migration files.
Returns a list of issues found (empty if OK).
"""
issues: list[str] = []
# Check for basic SQL syntax issues
lines = sql_content.strip().split("\n")
for i, line in enumerate(lines, 1):
stripped = line.strip()
if not stripped or stripped.startswith("--"):
continue
# Check for unclosed parentheses
if stripped.count("(") != stripped.count(")"):
issues.append(f"Line {i}: unbalanced parentheses")
# Check for DROP TABLE (dangerous in migrations)
if re.search(r"\bDROP\s+TABLE\b", stripped, re.IGNORECASE):
issues.append(f"Line {i}: DROP TABLE is not allowed in plugin migrations")
return issues
def _find_plugin_class_in_module(module: Any) -> type[BasePlugin] | None:
"""Find a BasePlugin subclass in a module."""
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
return attr
return None
def _extract_plugin_from_zip(zip_path: str) -> tuple[Path, str, type[BasePlugin]]:
"""Extract a ZIP file and find the plugin class.
Returns (extract_dir, plugin_name, plugin_class).
"""
extract_dir = Path(tempfile.mkdtemp(prefix="plugin_upload_"))
try:
with zipfile.ZipFile(zip_path, "r") as zf:
# Validate ZIP
bad_files = [f for f in zf.namelist() if f.startswith("..") or f.startswith("/")]
if bad_files:
raise ValueError(f"ZIP contains files with unsafe paths: {bad_files}")
zf.extractall(extract_dir)
# Find plugin.py in the extracted contents
plugin_py_path: Path | None = None
for fpath in extract_dir.rglob("plugin.py"):
plugin_py_path = fpath
break
if plugin_py_path is None:
raise ValueError("ZIP does not contain a plugin.py file")
# Security: check source code BEFORE executing it
source_code = plugin_py_path.read_text(encoding="utf-8")
dangerous = _check_dangerous_imports(source_code)
if dangerous:
raise ValueError(
f"Plugin contains dangerous patterns: {', '.join(dangerous)}"
)
# Import the module dynamically (safe — source validated above)
spec = importlib.util.spec_from_file_location(
"uploaded_plugin", plugin_py_path
)
if spec is None or spec.loader is None:
raise ValueError("Could not load plugin.py module")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Find BasePlugin subclass
plugin_class = _find_plugin_class_in_module(module)
if plugin_class is None:
raise ValueError(
"plugin.py does not contain a BasePlugin subclass"
)
plugin_instance = plugin_class()
plugin_name = plugin_instance.name
# Validate manifest
manifest = plugin_instance.manifest
if not manifest.name or not manifest.version or not manifest.display_name:
raise ValueError(
"Plugin manifest must include name, version, and display_name"
)
# Validate plugin name
_validate_manifest_name(manifest.name)
# Check migration SQL files
migrations_dir = plugin_py_path.parent / "migrations"
if migrations_dir.exists():
for sql_file in sorted(migrations_dir.glob("*.sql")):
sql_content = sql_file.read_text(encoding="utf-8")
issues = _check_migration_sql(sql_content)
if issues:
raise ValueError(
f"Migration file {sql_file.name} has issues: {'; '.join(issues)}"
)
return extract_dir, plugin_name, plugin_class
except Exception:
# Clean up on failure
shutil.rmtree(extract_dir, ignore_errors=True)
raise
def _install_plugin_from_dir(
extract_dir: Path,
plugin_name: str,
plugin_class: type[BasePlugin],
) -> None:
"""Copy plugin directory to builtins and register it.
Copies the extracted plugin directory to app/plugins/builtins/{plugin_name}/.
"""
builtins_dir = Path(__file__).parent.parent / "plugins" / "builtins" / plugin_name
builtins_dir.mkdir(parents=True, exist_ok=True)
# Copy all files from extract_dir to builtins_dir
for item in extract_dir.iterdir():
dest = builtins_dir / item.name
if item.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
# Register the plugin in the registry
registry = get_plugin_service().registry
instance = plugin_class()
registry.register_plugin(instance)
logger.info(
"Installed plugin '%s' from uploaded ZIP to %s",
plugin_name,
builtins_dir,
)
@router.post("/upload")
async def upload_plugin(
+25 -4
View File
@@ -16,6 +16,7 @@ from app.core.permission_registry import get_permission_registry
from app.core.permissions import invalidate_all_user_permissions
from app.deps import require_permission
from app.models.plugin import Plugin as PluginModel
from app.models.user import UserTenant
from app.plugins.registry import get_registry
from app.schemas.role import RoleCreate, RoleUpdate
from app.services.role_service import role_service
@@ -178,9 +179,19 @@ async def update_role(
if role is None:
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
# Invalidate permission cache for all users in this tenant
# Invalidate permission cache for all users across all their tenants
redis = get_redis()
await invalidate_all_user_permissions(redis, tenant_id)
tenant_ids = {tenant_id}
ut_q = select(UserTenant.tenant_id).where(
UserTenant.user_id.in_(
select(UserTenant.user_id).where(UserTenant.tenant_id == tenant_id)
)
)
ut_result = await db.execute(ut_q)
for (tid,) in ut_result.all():
tenant_ids.add(tid)
for tid in tenant_ids:
await invalidate_all_user_permissions(redis, tid)
# Audit log
acting_user_id = uuid.UUID(current_user["user_id"])
@@ -218,9 +229,19 @@ async def delete_role(
if not success:
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
# Invalidate permission cache for all users in this tenant
# Invalidate permission cache for all users across all their tenants
redis = get_redis()
await invalidate_all_user_permissions(redis, tenant_id)
tenant_ids = {tenant_id}
ut_q = select(UserTenant.tenant_id).where(
UserTenant.user_id.in_(
select(UserTenant.user_id).where(UserTenant.tenant_id == tenant_id)
)
)
ut_result = await db.execute(ut_q)
for (tid,) in ut_result.all():
tenant_ids.add(tid)
for tid in tenant_ids:
await invalidate_all_user_permissions(redis, tid)
acting_user_id = uuid.UUID(current_user["user_id"])
await log_audit(db, tenant_id, acting_user_id, "delete", "role", rid)
-4
View File
@@ -100,8 +100,6 @@ async def bulk_share(
})
logger.warning("Bulk share error for %s/%s: %s", entity_type, entity_uuid, e)
await db.commit()
return {
"created": created_count,
"updated": updated_count,
@@ -146,8 +144,6 @@ async def bulk_unshare(
"error": str(e),
})
await db.commit()
return {
"deleted": deleted_count,
"errors": errors,
-46
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import csv
import io
import uuid
from typing import Any
@@ -562,47 +560,3 @@ async def delete_contact_person(
cp.deleted_at = datetime.now(timezone.utc)
await db.flush()
async def export_contacts_csv(
db: AsyncSession, tenant_id: uuid.UUID, contact_type: str | None = None, search: str | None = None,
user_id: uuid.UUID | None = None, is_system_admin: bool = False,
) -> str:
"""Export contacts as CSV string. Only exports visible contacts."""
from app.core.visibility import apply_visibility_filter
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if contact_type:
base = base.where(Contact.type == contact_type)
if search:
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
# Apply visibility filter
if user_id and not is_system_admin:
base = await apply_visibility_filter(
db, base, "contact", Contact, user_id, tenant_id, is_system_admin
)
base = base.order_by(Contact.displayname)
result = await db.execute(base)
contacts = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"id", "type", "displayname", "name", "firstname", "surname", "code",
"email_1", "email_2", "phone_1", "phone_2", "website",
"mailing_city", "mailing_postalcode", "mailing_country",
"vat_code", "tags",
])
for c in contacts:
writer.writerow([
str(c.id), c.type, c.displayname, c.name or "", c.firstname or "", c.surname or "",
c.code or "", c.email_1 or "", c.email_2 or "", c.phone_1 or "", c.phone_2 or "",
c.website or "", c.mailing_city or "", c.mailing_postalcode or "",
c.mailing_country or "", c.vat_code or "", c.tags or "",
])
return output.getvalue()
+70
View File
@@ -116,6 +116,76 @@ try:
except ImportError:
pass
# Additional plugin models with OwnedMixin
try:
from app.plugins.builtins.mail.models import MailMessage
ENTITY_MODELS["mail_message"] = MailMessage
except ImportError:
pass
try:
from app.plugins.builtins.kommunikation.models import CommConversation
ENTITY_MODELS["comm_conversation"] = CommConversation
except ImportError:
pass
try:
from app.plugins.builtins.tags.models import Tag
ENTITY_MODELS["tag"] = Tag
except ImportError:
pass
try:
from app.plugins.builtins.agent_memory.models import AgentMemory
ENTITY_MODELS["agent_memory"] = AgentMemory
except ImportError:
pass
try:
from app.plugins.builtins.graph_rag.models import EntityRelationship
ENTITY_MODELS["entity_relationship"] = EntityRelationship
except ImportError:
pass
try:
from app.plugins.builtins.report_generator.models import ReportTemplate, ReportInstance
ENTITY_MODELS["report_template"] = ReportTemplate
ENTITY_MODELS["report_instance"] = ReportInstance
except ImportError:
pass
try:
from app.plugins.builtins.entity_links.models import EntityLink
ENTITY_MODELS["entity_link"] = EntityLink
except ImportError:
pass
try:
from app.plugins.builtins.kommunikation.models import CommConversation as CommConv
ENTITY_MODELS["comm_conversation"] = CommConv
except ImportError:
pass
try:
from app.plugins.builtins.ai_proactive.models import ProactiveSuggestion
ENTITY_MODELS["proactive_suggestion"] = ProactiveSuggestion
except ImportError:
pass
try:
from app.plugins.builtins.ai_assistant.models import AIAgent, AIChatSession
ENTITY_MODELS["ai_agent"] = AIAgent
ENTITY_MODELS["ai_chat_session"] = AIChatSession
except ImportError:
pass
try:
from app.plugins.builtins.permissions.models import ShareLink
ENTITY_MODELS["share_link"] = ShareLink
except ImportError:
pass
try:
from app.plugins.builtins.automation.models import AgentDefinition, AutomationDefinition
ENTITY_MODELS["agent_definition"] = AgentDefinition
ENTITY_MODELS["automation_definition"] = AutomationDefinition
except ImportError:
pass
try:
from app.plugins.builtins.mcp_client.models import McpServerConfig
ENTITY_MODELS["mcp_server_config"] = McpServerConfig
except ImportError:
pass
def _get_entity_model(entity_type: str) -> type:
"""Get SQLAlchemy model class for entity_type, or raise ValueError."""
+69
View File
@@ -0,0 +1,69 @@
"""Export service — CSV and other format exports for CRM entities."""
from __future__ import annotations
import csv
import io
import uuid
from typing import Any
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.contact import Contact
class ExportService:
"""Handles export operations for CRM entities."""
@staticmethod
async def export_contacts_csv(
db: AsyncSession,
tenant_id: uuid.UUID,
contact_type: str | None = None,
search: str | None = None,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> str:
"""Export contacts as CSV string. Only exports visible contacts."""
from app.core.visibility import apply_visibility_filter
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if contact_type:
base = base.where(Contact.type == contact_type)
if search:
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
# Apply visibility filter
if user_id and not is_system_admin:
base = await apply_visibility_filter(
db, base, "contact", Contact, user_id, tenant_id, is_system_admin
)
base = base.order_by(Contact.displayname)
result = await db.execute(base)
contacts = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"id", "type", "displayname", "name", "firstname", "surname", "code",
"email_1", "email_2", "phone_1", "phone_2", "website",
"mailing_city", "mailing_postalcode", "mailing_country",
"vat_code", "tags",
])
for c in contacts:
writer.writerow([
str(c.id), c.type, c.displayname, c.name or "", c.firstname or "", c.surname or "",
c.code or "", c.email_1 or "", c.email_2 or "", c.phone_1 or "", c.phone_2 or "",
c.website or "", c.mailing_city or "", c.mailing_postalcode or "",
c.mailing_country or "", c.vat_code or "", c.tags or "",
])
return output.getvalue()
export_service = ExportService()
+18
View File
@@ -9,6 +9,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import get_redis
from app.core.permissions import invalidate_all_user_permissions
from app.models.group import Group, UserGroup
from app.models.user import User, UserTenant
@@ -107,6 +108,12 @@ class GroupService:
group.permission_version += 1
await db.flush()
# Invalidate permission cache for all group members when permissions change
if version_bump:
redis = get_redis()
await invalidate_all_user_permissions(redis, tenant_id)
return group
async def delete_group(
@@ -192,6 +199,11 @@ class GroupService:
)
db.add(ug)
await db.flush()
# Invalidate permission cache for the added user
redis = get_redis()
await invalidate_all_user_permissions(redis, tenant_id)
return True
async def remove_user_from_group(
@@ -209,6 +221,12 @@ class GroupService:
)
result = await db.execute(q)
await db.flush()
# Invalidate permission cache for the removed user
if result.rowcount > 0:
redis = get_redis()
await invalidate_all_user_permissions(redis, tenant_id)
return result.rowcount > 0
async def get_user_groups(
+187
View File
@@ -0,0 +1,187 @@
"""Plugin install service — business logic for plugin installation from ZIP/URL."""
from __future__ import annotations
import importlib
import logging
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from app.plugins.base import BasePlugin
from app.services.plugin_service import get_plugin_service
logger = logging.getLogger(__name__)
class PluginInstallService:
"""Service layer for plugin installation operations.
Handles ZIP extraction, security validation, and plugin registration.
"""
@staticmethod
def validate_manifest_name(name: str) -> str:
"""Validate plugin name is alphanumeric with underscores only."""
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name):
raise ValueError(
f"Invalid plugin name '{name}': must start with a letter and contain only "
f"alphanumeric characters and underscores"
)
return name
@staticmethod
def check_dangerous_imports(source_code: str) -> list[str]:
"""Check plugin source for dangerous imports/patterns.
Returns a list of dangerous patterns found (empty if safe).
"""
dangerous_patterns = [
(r"\bos\.system\b", "os.system call"),
(r"\bsubprocess\.", "subprocess module"),
(r"\beval\s*\(", "eval() call"),
(r"\bexec\s*\(", "exec() call"),
(r"\b__import__\s*\(", "__import__() call"),
(r"\bcompile\s*\(", "compile() call"),
]
found: list[str] = []
for pattern, description in dangerous_patterns:
if re.search(pattern, source_code):
found.append(description)
return found
@staticmethod
def check_migration_sql(sql_content: str) -> list[str]:
"""Basic SQL validation for migration files.
Returns a list of issues found (empty if OK).
"""
issues: list[str] = []
lines = sql_content.strip().split("\n")
for i, line in enumerate(lines, 1):
stripped = line.strip()
if not stripped or stripped.startswith("--"):
continue
if stripped.count("(") != stripped.count(")"):
issues.append(f"Line {i}: unbalanced parentheses")
if re.search(r"\bDROP\s+TABLE\b", stripped, re.IGNORECASE):
issues.append(f"Line {i}: DROP TABLE is not allowed in plugin migrations")
return issues
@staticmethod
def find_plugin_class_in_module(module: Any) -> type[BasePlugin] | None:
"""Find a BasePlugin subclass in a module."""
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
return attr
return None
@staticmethod
def extract_plugin_from_zip(zip_path: str) -> tuple[Path, str, type[BasePlugin]]:
"""Extract a ZIP file and find the plugin class.
Returns (extract_dir, plugin_name, plugin_class).
"""
extract_dir = Path(tempfile.mkdtemp(prefix="plugin_upload_"))
try:
with zipfile.ZipFile(zip_path, "r") as zf:
bad_files = [f for f in zf.namelist() if f.startswith("..") or f.startswith("/")]
if bad_files:
raise ValueError(f"ZIP contains files with unsafe paths: {bad_files}")
zf.extractall(extract_dir)
plugin_py_path: Path | None = None
for fpath in extract_dir.rglob("plugin.py"):
plugin_py_path = fpath
break
if plugin_py_path is None:
raise ValueError("ZIP does not contain a plugin.py file")
source_code = plugin_py_path.read_text(encoding="utf-8")
dangerous = PluginInstallService.check_dangerous_imports(source_code)
if dangerous:
raise ValueError(
f"Plugin contains dangerous patterns: {', '.join(dangerous)}"
)
spec = importlib.util.spec_from_file_location(
"uploaded_plugin", plugin_py_path
)
if spec is None or spec.loader is None:
raise ValueError("Could not load plugin.py module")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plugin_class = PluginInstallService.find_plugin_class_in_module(module)
if plugin_class is None:
raise ValueError(
"plugin.py does not contain a BasePlugin subclass"
)
plugin_instance = plugin_class()
plugin_name = plugin_instance.name
manifest = plugin_instance.manifest
if not manifest.name or not manifest.version or not manifest.display_name:
raise ValueError(
"Plugin manifest must include name, version, and display_name"
)
PluginInstallService.validate_manifest_name(manifest.name)
migrations_dir = plugin_py_path.parent / "migrations"
if migrations_dir.exists():
for sql_file in sorted(migrations_dir.glob("*.sql")):
sql_content = sql_file.read_text(encoding="utf-8")
issues = PluginInstallService.check_migration_sql(sql_content)
if issues:
raise ValueError(
f"Migration file {sql_file.name} has issues: {'; '.join(issues)}"
)
return extract_dir, plugin_name, plugin_class
except Exception:
shutil.rmtree(extract_dir, ignore_errors=True)
raise
@staticmethod
def install_plugin_from_dir(
extract_dir: Path,
plugin_name: str,
plugin_class: type[BasePlugin],
) -> None:
"""Copy plugin directory to builtins and register it.
Copies the extracted plugin directory to app/plugins/builtins/{plugin_name}/.
"""
builtins_dir = Path(__file__).parent.parent / "plugins" / "builtins" / plugin_name
builtins_dir.mkdir(parents=True, exist_ok=True)
for item in extract_dir.iterdir():
dest = builtins_dir / item.name
if item.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
registry = get_plugin_service().registry
instance = plugin_class()
registry.register_plugin(instance)
logger.info(
"Installed plugin '%s' from uploaded ZIP to %s",
plugin_name,
builtins_dir,
)