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
@@ -0,0 +1,36 @@
"""Drop redundant DB roles (crm_platform_admin).
crm_runtime was already dropped in migration 0085.
crm_platform_admin was created in 0085 for one-time infrastructure use
and is no longer needed.
Revision ID: 0115
Revises: 0114
"""
from alembic import op
revision = "0115"
down_revision = "0114"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop crm_platform_admin if it exists
op.execute(
"DO $$ BEGIN "
"DROP ROLE IF EXISTS crm_platform_admin; "
"EXCEPTION WHEN insufficient_privilege THEN NULL; "
"WHEN dependent_objects_still_exist THEN NULL; "
"END $$;"
)
def downgrade() -> None:
# Recreate crm_platform_admin (for rollback)
op.execute(
"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_platform_admin') THEN "
"CREATE ROLE crm_platform_admin NOSUPERUSER NOBYPASSRLS NOLOGIN; "
"END IF; END $$;"
)
@@ -0,0 +1,79 @@
"""Merge deletion_log data into entity_history and drop deletion_log table.
DeletionLog has been merged into EntityHistory with action='delete'.
This migration migrates existing DeletionLog records and drops the table.
Revision ID: 0116
Revises: 0115
"""
from alembic import op
import sqlalchemy as sa
revision = "0116"
down_revision = "0115"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Migrate existing deletion_log records to entity_history
op.execute(
"""
INSERT INTO entity_history (id, tenant_id, user_id, entity_type, entity_id, action, snapshot_before, snapshot_after, changes, owner_id, created_at)
SELECT
gen_random_uuid(),
tenant_id,
user_id,
entity_type,
entity_id,
'delete'::text,
entity_snapshot::jsonb,
NULL::jsonb,
NULL::jsonb,
user_id,
deleted_at
FROM deletion_log
ON CONFLICT DO NOTHING;
"""
)
# Drop the deletion_log table
op.execute("DROP TABLE IF EXISTS deletion_log CASCADE;")
def downgrade() -> None:
# Recreate deletion_log table
op.execute(
"""
CREATE TABLE IF NOT EXISTS deletion_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
entity_snapshot JSONB NOT NULL,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
)
# Migrate data back from entity_history
op.execute(
"""
INSERT INTO deletion_log (id, tenant_id, user_id, entity_type, entity_id, entity_snapshot, deleted_at)
SELECT
gen_random_uuid(),
tenant_id,
user_id,
entity_type,
entity_id,
snapshot_before::jsonb,
created_at
FROM entity_history
WHERE action = 'delete' AND snapshot_before IS NOT NULL;
"""
)
# Remove migrated records from entity_history
op.execute("DELETE FROM entity_history WHERE action = 'delete' AND snapshot_before IS NOT NULL;")
+13 -5
View File
@@ -7,7 +7,8 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession 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( async def log_audit(
@@ -40,14 +41,21 @@ async def log_deletion(
entity_type: str, entity_type: str,
entity_id: uuid.UUID, entity_id: uuid.UUID,
entity_snapshot: dict[str, Any], entity_snapshot: dict[str, Any],
) -> DeletionLog: ) -> EntityHistory:
"""Create a deletion log entry (immutable snapshot).""" """Create a deletion history entry (merged from DeletionLog into EntityHistory).
entry = DeletionLog(
Stores the full entity snapshot in snapshot_before for forensic recovery.
"""
entry = EntityHistory(
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id, user_id=user_id,
entity_type=entity_type, entity_type=entity_type,
entity_id=entity_id, 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) db.add(entry)
await db.flush() 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.bank_account import BankAccount
from app.models.ai_conversation import AIConversation, AIMessage from app.models.ai_conversation import AIConversation, AIMessage
from app.models.attachment import Attachment 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.auth import ApiToken, PasswordResetToken
from app.models.contact import Contact, ContactPerson from app.models.contact import Contact, ContactPerson
from app.models.contact_folder import ContactFolder from app.models.contact_folder import ContactFolder
@@ -43,7 +43,6 @@ __all__ = [
"UserGroup", "UserGroup",
"Session", "Session",
"AuditLog", "AuditLog",
"DeletionLog",
"Notification", "Notification",
"NotificationType", "NotificationType",
"NotificationPreference", "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 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 from __future__ import annotations
@@ -33,21 +36,3 @@ class AuditLog(Base, TenantMixin):
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True 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 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: Each policy defines a rule for a specific entity type:
- allow policies: at least one must match for access - allow policies: at least one must match for access
- deny policies: if any matches, access is denied (deny takes precedence) - 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 from __future__ import annotations
+1 -1
View File
@@ -151,7 +151,7 @@ async def update_memory(
if "content" in data: if "content" in data:
memory.content = data["content"] memory.content = data["content"]
# Regenerate embedding for updated 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) embedding = await generate_embedding(data["content"], db=db, tenant_id=tenant_id)
if embedding: if embedding:
from sqlalchemy import text as sql_text 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 sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.agent_memory.models import AgentMemory 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( async def store_memory(
@@ -183,14 +183,14 @@ async def get_agent_status_external(
raise HTTPException(status_code=404, detail="Agent not found") raise HTTPException(status_code=404, detail="Agent not found")
# Get recent run stats # Get recent run stats
from app.plugins.builtins.automation.models import AgentRun from app.plugins.builtins.automation.contracts import AutomationContract
from sqlalchemy import func from sqlalchemy import func
recent_runs = await db.execute( recent_runs = await db.execute(
select(func.count()) select(func.count())
.select_from(AgentRun) .select_from(AutomationContract.AgentRun)
.where(AgentRun.agent_id == aid) .where(AutomationContract.AgentRun.agent_id == aid)
.where(AgentRun.tenant_id == tenant_id) .where(AutomationContract.AgentRun.tenant_id == tenant_id)
) )
total_runs = recent_runs.scalar() or 0 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 import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.deps import require_permission
from app.plugins.builtins.ai_ui_control.schemas import ( from app.plugins.builtins.ai_ui_control.schemas import (
UICommand, UICommand,
UICommandCreate, UICommandCreate,
@@ -31,7 +32,7 @@ router = APIRouter(prefix="/api/v1/ai-ui-control", tags=["ai-ui-control"])
# ─── REST endpoints (for AI agents) ─── # ─── 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( async def send_ui_command(
request: Request, request: Request,
body: UICommandCreate, 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): async def get_online_users(request: Request):
"""Check which users are currently online (have active frontend WS connections).""" """Check which users are currently online (have active frontend WS connections)."""
from app.config import get_settings from app.config import get_settings
@@ -263,7 +263,8 @@ class AgentCoordinator:
def register_agent_coordinator_tools(): def register_agent_coordinator_tools():
"""Register AgentCoordinator tools in the global tool registry.""" """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() registry = get_tool_registry()
@@ -355,7 +356,8 @@ def register_agent_coordinator_tools():
def unregister_agent_coordinator_tools(): def unregister_agent_coordinator_tools():
"""Unregister AgentCoordinator 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 = get_tool_registry()
registry.unregister("create_subtask") registry.unregister("create_subtask")
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.visibility import apply_visibility_filter from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission 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.models import EntityLink
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest 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"} 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 # Check if link already exists
existing = await db.execute( existing = await db.execute(
select(EntityLink).where( select(EntityLink).where(
@@ -4,14 +4,16 @@ from __future__ import annotations
import logging import logging
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.deps import require_permission
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/forgejo-error-reporter", tags=["forgejo_error_reporter"]) 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: async def get_status() -> dict:
"""Get the current status of the Forgejo Error Reporter plugin.""" """Get the current status of the Forgejo Error Reporter plugin."""
from app.plugins.registry import get_registry 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: async def on_activate(self, db, service_container, event_bus) -> None:
"""Activate plugin: register GraphRAG search provider.""" """Activate plugin: register GraphRAG search provider."""
from app.plugins.builtins.graph_rag.provider import GraphRAGSearchProvider 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() registry = get_search_registry()
try: try:
@@ -50,7 +50,7 @@ class GraphRAGPlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None: async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Deactivate plugin: unregister search provider and contract.""" """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") get_search_registry().unregister("graph_relationship")
from app.plugins.builtins.contracts import get_contract_registry 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 import text
from sqlalchemy.ext.asyncio import AsyncSession 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__) logger = logging.getLogger(__name__)
@@ -13,7 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.storage import get_storage_backend from app.core.storage import get_storage_backend
from app.plugins.builtins.permissions.models import ShareLink 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"]) 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.contracts import get_contract_registry
from app.plugins.builtins.unified_search.embedding import generate_embedding 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.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: class UnifiedSearchContract:
@@ -14,6 +16,8 @@ class UnifiedSearchContract:
generate_embedding = staticmethod(generate_embedding) generate_embedding = staticmethod(generate_embedding)
hybrid_search = staticmethod(hybrid_search) hybrid_search = staticmethod(hybrid_search)
get_search_registry = staticmethod(get_search_registry)
BaseSearchProvider = BaseSearchProvider
# ─── self-registration ─── # ─── self-registration ───
@@ -32,4 +36,4 @@ def get_contract() -> UnifiedSearchContract:
return _contract_instance 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 ( from app.plugins.builtins.unified_search.providers.user_provider import (
UserSearchProvider, UserSearchProvider,
) )
from app.plugins.builtins.graph_rag.provider import ( from app.plugins.builtins.graph_rag.contracts import GraphRagContract
GraphRAGSearchProvider, GraphRAGSearchProvider = GraphRagContract.GraphRAGSearchProvider
)
registry = get_search_registry() registry = get_search_registry()
registry.clear() registry.clear()
+1 -1
View File
@@ -100,7 +100,7 @@ async def export_contacts(
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False) 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, db, tenant_id, contact_type=type, search=search,
user_id=user_id, is_system_admin=is_admin, user_id=user_id, is_system_admin=is_admin,
) )
+1 -189
View File
@@ -2,27 +2,18 @@
from __future__ import annotations from __future__ import annotations
import importlib
import logging import logging
import os
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any from typing import Any
import httpx
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import require_permission, require_admin 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.plugins.migration_runner import MigrationValidationError
from app.services.plugin_service import get_plugin_service from app.services.plugin_service import get_plugin_service
from app.services.plugin_install_service import PluginInstallService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -293,185 +284,6 @@ async def uninstall_plugin(
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None 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") @router.post("/upload")
async def upload_plugin( 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.core.permissions import invalidate_all_user_permissions
from app.deps import require_permission from app.deps import require_permission
from app.models.plugin import Plugin as PluginModel from app.models.plugin import Plugin as PluginModel
from app.models.user import UserTenant
from app.plugins.registry import get_registry from app.plugins.registry import get_registry
from app.schemas.role import RoleCreate, RoleUpdate from app.schemas.role import RoleCreate, RoleUpdate
from app.services.role_service import role_service from app.services.role_service import role_service
@@ -178,9 +179,19 @@ async def update_role(
if role is None: if role is None:
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"}) 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() 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 # Audit log
acting_user_id = uuid.UUID(current_user["user_id"]) acting_user_id = uuid.UUID(current_user["user_id"])
@@ -218,9 +229,19 @@ async def delete_role(
if not success: if not success:
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"}) 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() 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"]) acting_user_id = uuid.UUID(current_user["user_id"])
await log_audit(db, tenant_id, acting_user_id, "delete", "role", rid) 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) logger.warning("Bulk share error for %s/%s: %s", entity_type, entity_uuid, e)
await db.commit()
return { return {
"created": created_count, "created": created_count,
"updated": updated_count, "updated": updated_count,
@@ -146,8 +144,6 @@ async def bulk_unshare(
"error": str(e), "error": str(e),
}) })
await db.commit()
return { return {
"deleted": deleted_count, "deleted": deleted_count,
"errors": errors, "errors": errors,
-46
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
import csv
import io
import uuid import uuid
from typing import Any from typing import Any
@@ -562,47 +560,3 @@ async def delete_contact_person(
cp.deleted_at = datetime.now(timezone.utc) cp.deleted_at = datetime.now(timezone.utc)
await db.flush() 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: except ImportError:
pass 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: def _get_entity_model(entity_type: str) -> type:
"""Get SQLAlchemy model class for entity_type, or raise ValueError.""" """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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.auth import get_redis
from app.core.permissions import invalidate_all_user_permissions from app.core.permissions import invalidate_all_user_permissions
from app.models.group import Group, UserGroup from app.models.group import Group, UserGroup
from app.models.user import User, UserTenant from app.models.user import User, UserTenant
@@ -107,6 +108,12 @@ class GroupService:
group.permission_version += 1 group.permission_version += 1
await db.flush() 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 return group
async def delete_group( async def delete_group(
@@ -192,6 +199,11 @@ class GroupService:
) )
db.add(ug) db.add(ug)
await db.flush() await db.flush()
# Invalidate permission cache for the added user
redis = get_redis()
await invalidate_all_user_permissions(redis, tenant_id)
return True return True
async def remove_user_from_group( async def remove_user_from_group(
@@ -209,6 +221,12 @@ class GroupService:
) )
result = await db.execute(q) result = await db.execute(q)
await db.flush() 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 return result.rowcount > 0
async def get_user_groups( 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,
)
+3 -2
View File
@@ -68,8 +68,9 @@ apiClient.interceptors.response.use(
const config = error.config as InternalAxiosRequestConfig & { _retried?: boolean }; const config = error.config as InternalAxiosRequestConfig & { _retried?: boolean };
const status = error.response?.status || 0; const status = error.response?.status || 0;
// Retry 5xx errors once with a short delay // Retry 5xx errors once with a short delay (GET only — never retry mutations)
if (status >= 500 && status < 600 && config && !config._retried) { const method = config?.method?.toLowerCase() ?? '';
if (status >= 500 && status < 600 && method === 'get' && config && !config._retried) {
config._retried = true; config._retried = true;
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
return apiClient.request(config); return apiClient.request(config);
+2 -13
View File
@@ -1,5 +1,4 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface Tenant { export interface Tenant {
id: string; id: string;
@@ -35,8 +34,7 @@ export interface AuthState {
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( (set) => ({
(set) => ({
user: null, user: null,
currentTenant: null, currentTenant: null,
isAuthenticated: false, isAuthenticated: false,
@@ -70,14 +68,5 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false, isAuthenticated: false,
error: null, error: null,
}), }),
}), })
{
name: 'auth-store',
partialize: (state) => ({
user: state.user,
currentTenant: state.currentTenant,
isAuthenticated: state.isAuthenticated,
}),
}
)
); );