From 0eb6d7621e2390d2ffbb777787167108ca6099f0 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 6 Aug 2026 13:23:58 +0200 Subject: [PATCH] fix(security): 16 mittlere Probleme behoben (P18-P33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../versions/0115_drop_redundant_db_roles.py | 36 ++++ ..._merge_deletion_log_into_entity_history.py | 79 ++++++++ app/core/audit.py | 18 +- app/models/__init__.py | 3 +- app/models/address.py | 6 +- app/models/audit.py | 23 +-- app/models/entity_policy.py | 3 + app/models/session.py | 6 +- app/plugins/builtins/agent_memory/routes.py | 2 +- app/plugins/builtins/agent_memory/services.py | 2 +- .../builtins/ai_assistant/external_api.py | 8 +- app/plugins/builtins/ai_ui_control/routes.py | 5 +- .../builtins/automation/agent_coordinator.py | 6 +- app/plugins/builtins/entity_links/routes.py | 9 + .../builtins/forgejo_error_reporter/routes.py | 6 +- app/plugins/builtins/graph_rag/contracts.py | 33 +++ app/plugins/builtins/graph_rag/plugin.py | 4 +- app/plugins/builtins/graph_rag/provider.py | 2 +- .../builtins/permissions/public_routes.py | 3 +- .../builtins/unified_search/contracts.py | 6 +- .../unified_search/provider_registry.py | 5 +- app/routes/contacts.py | 2 +- app/routes/plugins.py | 190 +----------------- app/routes/roles.py | 29 ++- app/services/bulk_permission_service.py | 4 - app/services/contact_service.py | 46 ----- app/services/entity_permission_service.py | 70 +++++++ app/services/export_service.py | 69 +++++++ app/services/group_service.py | 18 ++ app/services/plugin_install_service.py | 187 +++++++++++++++++ frontend/src/api/client.ts | 5 +- frontend/src/store/authStore.ts | 15 +- 32 files changed, 593 insertions(+), 307 deletions(-) create mode 100644 alembic/versions/0115_drop_redundant_db_roles.py create mode 100644 alembic/versions/0116_merge_deletion_log_into_entity_history.py create mode 100644 app/plugins/builtins/graph_rag/contracts.py create mode 100644 app/services/export_service.py create mode 100644 app/services/plugin_install_service.py diff --git a/alembic/versions/0115_drop_redundant_db_roles.py b/alembic/versions/0115_drop_redundant_db_roles.py new file mode 100644 index 0000000..0bad971 --- /dev/null +++ b/alembic/versions/0115_drop_redundant_db_roles.py @@ -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 $$;" + ) diff --git a/alembic/versions/0116_merge_deletion_log_into_entity_history.py b/alembic/versions/0116_merge_deletion_log_into_entity_history.py new file mode 100644 index 0000000..04f7ca5 --- /dev/null +++ b/alembic/versions/0116_merge_deletion_log_into_entity_history.py @@ -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;") diff --git a/app/core/audit.py b/app/core/audit.py index da8d188..179d2cb 100644 --- a/app/core/audit.py +++ b/app/core/audit.py @@ -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() diff --git a/app/models/__init__.py b/app/models/__init__.py index c57348d..46a2101 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", diff --git a/app/models/address.py b/app/models/address.py index 4b90f73..a34cc58 100644 --- a/app/models/address.py +++ b/app/models/address.py @@ -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 diff --git a/app/models/audit.py b/app/models/audit.py index c009d30..1a5648d 100644 --- a/app/models/audit.py +++ b/app/models/audit.py @@ -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() - ) diff --git a/app/models/entity_policy.py b/app/models/entity_policy.py index cbe2237..c544b2f 100644 --- a/app/models/entity_policy.py +++ b/app/models/entity_policy.py @@ -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) diff --git a/app/models/session.py b/app/models/session.py index 2bdddc1..5a6b5e1 100644 --- a/app/models/session.py +++ b/app/models/session.py @@ -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 diff --git a/app/plugins/builtins/agent_memory/routes.py b/app/plugins/builtins/agent_memory/routes.py index d2779e1..2a79390 100644 --- a/app/plugins/builtins/agent_memory/routes.py +++ b/app/plugins/builtins/agent_memory/routes.py @@ -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 diff --git a/app/plugins/builtins/agent_memory/services.py b/app/plugins/builtins/agent_memory/services.py index ddb56a2..ad9723e 100644 --- a/app/plugins/builtins/agent_memory/services.py +++ b/app/plugins/builtins/agent_memory/services.py @@ -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( diff --git a/app/plugins/builtins/ai_assistant/external_api.py b/app/plugins/builtins/ai_assistant/external_api.py index 79c4838..850242d 100644 --- a/app/plugins/builtins/ai_assistant/external_api.py +++ b/app/plugins/builtins/ai_assistant/external_api.py @@ -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 diff --git a/app/plugins/builtins/ai_ui_control/routes.py b/app/plugins/builtins/ai_ui_control/routes.py index 32dc92f..b8f7605 100644 --- a/app/plugins/builtins/ai_ui_control/routes.py +++ b/app/plugins/builtins/ai_ui_control/routes.py @@ -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 diff --git a/app/plugins/builtins/automation/agent_coordinator.py b/app/plugins/builtins/automation/agent_coordinator.py index 149f1c6..2236b12 100644 --- a/app/plugins/builtins/automation/agent_coordinator.py +++ b/app/plugins/builtins/automation/agent_coordinator.py @@ -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") diff --git a/app/plugins/builtins/entity_links/routes.py b/app/plugins/builtins/entity_links/routes.py index fc15773..8622e7e 100644 --- a/app/plugins/builtins/entity_links/routes.py +++ b/app/plugins/builtins/entity_links/routes.py @@ -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( diff --git a/app/plugins/builtins/forgejo_error_reporter/routes.py b/app/plugins/builtins/forgejo_error_reporter/routes.py index 0567872..9ef93b8 100644 --- a/app/plugins/builtins/forgejo_error_reporter/routes.py +++ b/app/plugins/builtins/forgejo_error_reporter/routes.py @@ -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 diff --git a/app/plugins/builtins/graph_rag/contracts.py b/app/plugins/builtins/graph_rag/contracts.py new file mode 100644 index 0000000..cef8aba --- /dev/null +++ b/app/plugins/builtins/graph_rag/contracts.py @@ -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"] diff --git a/app/plugins/builtins/graph_rag/plugin.py b/app/plugins/builtins/graph_rag/plugin.py index b1945f1..16982f4 100644 --- a/app/plugins/builtins/graph_rag/plugin.py +++ b/app/plugins/builtins/graph_rag/plugin.py @@ -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 diff --git a/app/plugins/builtins/graph_rag/provider.py b/app/plugins/builtins/graph_rag/provider.py index 8fc9e8e..d462386 100644 --- a/app/plugins/builtins/graph_rag/provider.py +++ b/app/plugins/builtins/graph_rag/provider.py @@ -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__) diff --git a/app/plugins/builtins/permissions/public_routes.py b/app/plugins/builtins/permissions/public_routes.py index a667a46..b7f1949 100644 --- a/app/plugins/builtins/permissions/public_routes.py +++ b/app/plugins/builtins/permissions/public_routes.py @@ -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"]) diff --git a/app/plugins/builtins/unified_search/contracts.py b/app/plugins/builtins/unified_search/contracts.py index 550161c..d421cef 100644 --- a/app/plugins/builtins/unified_search/contracts.py +++ b/app/plugins/builtins/unified_search/contracts.py @@ -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"] diff --git a/app/plugins/builtins/unified_search/provider_registry.py b/app/plugins/builtins/unified_search/provider_registry.py index 099d252..9ee9ec8 100644 --- a/app/plugins/builtins/unified_search/provider_registry.py +++ b/app/plugins/builtins/unified_search/provider_registry.py @@ -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() diff --git a/app/routes/contacts.py b/app/routes/contacts.py index 2169f28..8010e2b 100644 --- a/app/routes/contacts.py +++ b/app/routes/contacts.py @@ -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, ) diff --git a/app/routes/plugins.py b/app/routes/plugins.py index dfa6654..6ec05a7 100644 --- a/app/routes/plugins.py +++ b/app/routes/plugins.py @@ -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( diff --git a/app/routes/roles.py b/app/routes/roles.py index c5fe77b..0947cd3 100644 --- a/app/routes/roles.py +++ b/app/routes/roles.py @@ -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) diff --git a/app/services/bulk_permission_service.py b/app/services/bulk_permission_service.py index c1b39e2..d95a346 100644 --- a/app/services/bulk_permission_service.py +++ b/app/services/bulk_permission_service.py @@ -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, diff --git a/app/services/contact_service.py b/app/services/contact_service.py index df8a610..f4a4202 100644 --- a/app/services/contact_service.py +++ b/app/services/contact_service.py @@ -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() diff --git a/app/services/entity_permission_service.py b/app/services/entity_permission_service.py index 7e99918..6d2a771 100644 --- a/app/services/entity_permission_service.py +++ b/app/services/entity_permission_service.py @@ -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.""" diff --git a/app/services/export_service.py b/app/services/export_service.py new file mode 100644 index 0000000..47ecc0a --- /dev/null +++ b/app/services/export_service.py @@ -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() diff --git a/app/services/group_service.py b/app/services/group_service.py index d5ae7c0..70eb53b 100644 --- a/app/services/group_service.py +++ b/app/services/group_service.py @@ -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( diff --git a/app/services/plugin_install_service.py b/app/services/plugin_install_service.py new file mode 100644 index 0000000..540bd42 --- /dev/null +++ b/app/services/plugin_install_service.py @@ -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, + ) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e8e59b8..e3d4545 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -68,8 +68,9 @@ apiClient.interceptors.response.use( const config = error.config as InternalAxiosRequestConfig & { _retried?: boolean }; const status = error.response?.status || 0; - // Retry 5xx errors once with a short delay - if (status >= 500 && status < 600 && config && !config._retried) { + // Retry 5xx errors once with a short delay (GET only — never retry mutations) + const method = config?.method?.toLowerCase() ?? ''; + if (status >= 500 && status < 600 && method === 'get' && config && !config._retried) { config._retried = true; await new Promise((resolve) => setTimeout(resolve, 500)); return apiClient.request(config); diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 55e2f49..2413d2d 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,5 +1,4 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; export interface Tenant { id: string; @@ -35,8 +34,7 @@ export interface AuthState { } export const useAuthStore = create()( - persist( - (set) => ({ + (set) => ({ user: null, currentTenant: null, isAuthenticated: false, @@ -70,14 +68,5 @@ export const useAuthStore = create()( isAuthenticated: false, error: null, }), - }), - { - name: 'auth-store', - partialize: (state) => ({ - user: state.user, - currentTenant: state.currentTenant, - isAuthenticated: state.isAuthenticated, - }), - } - ) + }) );