From e17b9c9e569ee0fd9f0e24ff8da98aaf323eb6eb Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 4 Aug 2026 00:03:29 +0200 Subject: [PATCH] Phase 2: Visibility Filter & Owner ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OwnedMixin to 15 models (contact_folder, user_preference, workspace, mcp_server_config, agent_definition, automation_definition, report_template, report_instance, entity_link, comm_conversation, proactive_suggestion, ai_agent, ai_chat_session, tag, share_link) - Migration 0102: Add owner_id column to 15 tables with backfill from user_id - Fix EntityPermission Registry: remove notification, add entity_attachment, entity_history, subtask, calendar, folder; fix wrong class names (DmsFile→File, CalendarEvent→CalendarEntry, Mailbox→MailAccount) - Add apply_visibility_filter to list endpoints in tags, tasks, mcp_client, automation, report_generator, ai_assistant routes - Add owner_id to create handlers for all new OwnedMixin models - Patch tasks/services.py and automation/services.py list methods with user_id and is_system_admin parameters --- SECURITY_FIX_PLAN.md | 170 ++++++++++++++++++ .../0102_add_owner_id_to_phase2_tables.py | 69 +++++++ app/models/contact_folder.py | 3 +- app/models/user_preference.py | 3 +- app/models/workspace.py | 3 +- app/plugins/builtins/ai_assistant/models.py | 5 +- app/plugins/builtins/ai_assistant/routes.py | 16 +- app/plugins/builtins/ai_proactive/models.py | 3 +- app/plugins/builtins/ai_proactive/routes.py | 1 + .../builtins/automation/agent_routes.py | 6 +- app/plugins/builtins/automation/models.py | 5 +- app/plugins/builtins/automation/routes.py | 4 + app/plugins/builtins/automation/services.py | 15 ++ app/plugins/builtins/entity_links/models.py | 3 +- app/plugins/builtins/entity_links/routes.py | 3 + app/plugins/builtins/kommunikation/models.py | 3 +- app/plugins/builtins/kommunikation/routes.py | 1 + .../builtins/kommunikation/services.py | 2 + app/plugins/builtins/mcp_client/models.py | 3 +- app/plugins/builtins/mcp_client/routes.py | 10 +- app/plugins/builtins/permissions/models.py | 3 +- app/plugins/builtins/permissions/routes.py | 4 + .../builtins/report_generator/models.py | 5 +- .../builtins/report_generator/routes.py | 16 +- app/plugins/builtins/tags/models.py | 3 +- app/plugins/builtins/tags/routes.py | 12 +- app/plugins/builtins/tasks/routes.py | 4 + app/plugins/builtins/tasks/services.py | 9 + app/services/entity_permission_service.py | 43 ++++- 29 files changed, 391 insertions(+), 36 deletions(-) create mode 100644 SECURITY_FIX_PLAN.md create mode 100644 alembic/versions/0102_add_owner_id_to_phase2_tables.py diff --git a/SECURITY_FIX_PLAN.md b/SECURITY_FIX_PLAN.md new file mode 100644 index 0000000..b3e117e --- /dev/null +++ b/SECURITY_FIX_PLAN.md @@ -0,0 +1,170 @@ +# LeoCRM Security Fix Plan + +## Phase 1 — Kritische Sicherheitslücken (~8h) + +### 1.1 ✅ 59 Permissions im Registry ergänzen +- 98 Permissions in Routes verwendet, nur 39 in CORE_PERMISSIONS +- Fehlend: ai:read, ai:write, automation:admin, mcp:read, mcp:write, calendar:read, dms:read, mail:read, tasks:read, tags:read, reports:read, search:read, agents:read, permissions:delegations:read, etc. +- Status: Implementiert und committed + +### 1.2 ✅ Grants einschränken (Migration 0100) +- crm_api und crm_worker haben DELETE auf 12 sensitiven Tabellen: api_tokens, audit_log, notification_types, password_reset_tokens, plugin_allowlist, plugin_migrations, plugins, sessions, tenant_plugin_activation, tenants, user_tenants, users +- Fix: DELETE für crm_api und crm_worker auf diesen Tabellen entfernen +- crm_auth behält DELETE auf sessions + password_reset_tokens (für Logout/Reset) +- Status: Migration erstellt und committed + +### 1.3 ❌ RLS auf Tabellen — ENTFERNT +- RLS auf sessions, password_reset_tokens, api_tokens, sequences, tenant_plugin_activation, user_tenants +- PROBLEM: Diese Tabellen werden vor/ohne Tenant-Context abgefragt → RLS blockiert Login/App-Startup +- WICHTIG: Kein RLS auf Tabellen die den Login blockieren! +- Status: Komplett aus Migration entfernt. Tabellen haben kein RLS wie vor der Änderung + +### 1.4 ✅ Mass-Assignment Schutz +- UserCreate.role war setzbar (default viewer aber Client konnte admin senden) +- UserUpdate.role war setzbar +- Fix: UserCreate role=admin nur für is_system_admin. UserUpdate role=admin nur für is_system_admin +- Status: Implementiert und committed + +### 1.5 ✅ Entity Permission Ownership-Check (PUT) +- PUT /permissions/{type}/{id}/{pid} hatte keinen Ownership-Check +- DELETE hatte einen Check +- Fix: _check_entity_ownership Hilfsfunktion, in PUT ergänzt +- Status: Implementiert und committed + +### 1.6 ✅ AttachmentResponse file_path entfernt +- file_path: str in Schema Zeile 13 exponiert internen Storage-Pfad +- Fix: Aus Schema entfernt +- Status: Implementiert und committed + +### 1.7 ✅ SystemSettings sensible Felder maskiert +- tax_number, iban, bic in Response Schema für alle sichtbar +- Fix: Für non-admin User maskiert ("********") +- Status: Implementiert und committed + +### 1.8 ✅ File Upload MIME-Validierung + Extensions +- Nur Extension-Blocklist (ohne .php, .py, .asp, .jsp, .svg) + client-seitiger content_type (fälschbar) +- Fix: BLOCKED_EXTENSIONS ergänzt (.php, .py, .pl, .asp, .aspx, .jsp, .svg, .htaccess, .phtml, .pht, .cgi, .cfm, .erb) + ALLOWED_MIME_PREFIXES Whitelist + MIME-Validierung in upload_file +- Status: Implementiert und committed + +--- + +## Phase 2 — Visibility Filter & Owner ID (~12h) + +### 2.1 Visibility Filter in 17 Services einbauen +- ai_assistant, ai_proactive, automation, entity_links, kommunikation, mail, mcp_client, permissions, report_generator, tags, tasks, entity_permission_service, user_service, workspace_service +- apply_visibility_filter funktioniert korrekt (tenant_id + owner_id + shared permissions + admin bypass) + +### 2.2 owner_id auf 26 Modellen ergänzen +- Core (15): auth, contact_folder, contact_folder_permission, contact_merge, currency, entity_policy, group, guest_user, outbox, plugin, system_settings, tax, user, user_preference, workspace +- Plugins (11): mcp_client, automation, unified_search, report_generator, entity_links, kommunikation, ai_proactive, ai_assistant, tags, permissions + +### 2.3 EntityPermission Registry korrigieren +- notification, contact_folder entfernen (kein owner_id) +- entity_attachment, entity_history, subtask, calendar, folder hinzufügen + +--- + +## Phase 3 — Weitere Sicherheitslücken (~4h) + +### 3.1 WebSocket CSRF implementieren +- Kommentar sagt "skip CSRF for now" — nicht implementiert +- Fix: CSRF-Token aus Query-Parameter validieren + +### 3.2 SameSite auf Lax +- session_cookie_samesite = "strict" blockiert WebSocket +- Fix: Auf "lax" ändern + +### 3.3 Tenant FK CASCADE +- 3 Tabellen ohne CASCADE (contact_merge_history, tenant_plugin_activation, user_tenants) +- 10 Tabellen mit tenant_id aber ohne FK +- Fix: CASCADE ergänzen, fehlende FKs hinzufügen + +--- + +## Phase 4 — Krisensicherheit (~9h) + +### 4.1 Redis Fallback / Graceful Degradation +- Bei Redis-Ausfall funktioniert nichts mehr +- Fix: Session-Check → DB-Fallback, Permission-Cache → DB-Fallback, Rate-Limiting → in-memory Fallback + +### 4.2 DB-Connection Retry +- Bei kurzem DB-Ausfall gibt es sofort 500er +- Fix: Retry-Decorator (3 Versuche), 503 statt 500 + +### 4.3 Circuit Breaker Middleware +- Bei wiederholten Fehlern kein automatisches Fallback +- Fix: Bei 5 Fehlern in 30s → Circuit öffnet → 503 für 60s → Half-Open → probieren + +--- + +## Phase 5 — Architektur-Lücken (~40h) + +### 5.1 Öffentliche Plugin-Endpoints +- Alle Plugin-Routes erfordern Auth +- Fix: get_public_routes() Mechanismus, separate Router-Mountung ohne Auth-Dependency + +### 5.2 PWA aktivieren +- Dateien da aber Vite Plugin nicht konfiguriert +- Fix: vite-plugin-pwa installieren + konfigurieren, manifest.json, Service Worker + +### 5.3 Contacts embedding + Auto-Index +- contacts hat KEINE embedding column — wichtigste Tabelle kann nicht semantisch suchen +- Fix: Migration: ADD COLUMN embedding vector(768), CREATE INDEX, Auto-Indexierung + +### 5.4 Search Engine: alle Tabellen abdecken +- Nur 5 Tabellen in Suche (contacts, mails, files, calendar_entries, comm_messages) +- Fehlend: tasks, contactpersons, tags, comm_conversations, calendars, users, workflows, automation_runs, resources, ai_chat_sessions + +### 5.5 Plugin-Marketplace +- Grundlage da (discover_external, plugin_allowlist, install from ZIP) +- Fehlend: Echter Marketplace-Server, Plugin-Signatur-Verifikation, Plugin-Versionierung, UI + +### 5.6 Agent Memory (persistent) +- Kein persistentes Agent Memory (nur Session-History) +- Fix: agent_memories Tabelle mit embeddings, semantische Suche bei neuen Konversationen + +### 5.7 GraphRAG als Provider +- Keine Graph-Struktur in DB oder Code +- Fix: entity_relationships Tabelle, Graph-Traversal, als Provider in unified_search + +### 5.8 Subagents / Multi-Agent +- Keine Subagents, keine Multi-Agent-Orchestrierung +- automation/agent_comm.py hat Messaging-Infrastruktur +- Fix: Agent kann Agent aufrufen, Agent-Coordinator + +### 5.9 Agent von außen erreichbar +- Agent nur über WebUI erreichbar +- Fix: API-Endpoint für externen Agent-Zugriff + +--- + +## Verifizierte Fakten + +| Punkt | Ergebnis | +|-------|---------| +| 59 Permissions fehlen im Registry | ✅ Bestätigt | +| crm_api + crm_worker DELETE auf 12 Tabellen | ✅ Bestätigt | +| 5 Tabellen mit tenant_id aber ohne RLS | ✅ Bestätigt | +| UserCreate.role setzbar | ✅ Bestätigt | +| UserUpdate.role setzbar | ✅ Bestätigt | +| PUT Entity Permission ohne Ownership-Check | ✅ Bestätigt | +| AttachmentResponse.file_path exponiert | ✅ Bestätigt | +| SystemSettings exponiert IBAN/BIC/Steuernummer | ✅ Bestätigt | +| SameSite = strict | ✅ Bestätigt | +| 17 Services ohne Visibility Filter | ✅ Bestätigt | +| 26 Modelle ohne owner_id | ✅ Bestätigt | +| WebSocket CSRF nicht implementiert | ✅ Bestätigt | +| File Upload ohne echte MIME-Validierung | ✅ Bestätigt | +| .env nicht in Git | ✅ Bereits gefixt | +| password_reset_tokens RLS qual=true | ❌ War falsch — Tabelle hatte kein RLS | +| DELETE Entity Permission ohne Ownership | ❌ War falsch — DELETE hat Check | + +--- + +## WICHTIGE REGELN + +- KEIN manuelles Rumgepfusche auf der Produktions-DB +- KEIN RLS auf Tabellen die den Login blockieren (sessions, password_reset_tokens, api_tokens, user_tenants, sequences, tenant_plugin_activation) +- Deploy NUR über Coolify Tool (deploy_start) +- Bei Deploy-Fehlern: Coolify DB nach Logs queryen, nicht manuell eingreifen +- Login-Logik NIEMALS ändern diff --git a/alembic/versions/0102_add_owner_id_to_phase2_tables.py b/alembic/versions/0102_add_owner_id_to_phase2_tables.py new file mode 100644 index 0000000..fcebc71 --- /dev/null +++ b/alembic/versions/0102_add_owner_id_to_phase2_tables.py @@ -0,0 +1,69 @@ +"""Add owner_id column to Phase 2 tables for row-level ownership. + +Adds nullable owner_id (FK → users.id, ON DELETE SET NULL) to tables +that gained OwnedMixin in Phase 2. For tables that already have a +non-nullable user_id column, owner_id is backfilled from user_id. + +Revision ID: 0102 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PGUUID + +revision = "0102" +down_revision = "0101" +branch_labels = None +depends_on = None + +# (table_name, has_user_id_to_backfill) +TABLES = [ + # Core models + ("contact_folders", True), + ("user_preferences", True), + ("workspaces", False), + # Plugin models + ("mcp_server_configs", False), + ("automation_agent_definitions", False), + ("automation_definitions", False), + ("report_templates", False), + ("report_instances", False), + ("entity_links", False), + ("comm_conversations", False), + ("ai_proactive_suggestions", True), + ("ai_agents", False), + ("ai_chat_sessions", True), + ("tags", False), + ("share_links", True), +] + + +def upgrade() -> None: + for table_name, _has_user_id in TABLES: + op.add_column( + table_name, + sa.Column( + "owner_id", + PGUUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index( + f"ix_{table_name}_owner_id", + table_name, + ["owner_id"], + ) + + # Backfill owner_id from user_id where available + for table_name, has_user_id in TABLES: + if has_user_id: + op.execute( + f"UPDATE {table_name} SET owner_id = user_id WHERE owner_id IS NULL;" + ) + + +def downgrade() -> None: + for table_name, _ in TABLES: + op.drop_index(f"ix_{table_name}_owner_id", table_name=table_name) + op.drop_column(table_name, "owner_id") diff --git a/app/models/contact_folder.py b/app/models/contact_folder.py index afe0614..9a5357e 100644 --- a/app/models/contact_folder.py +++ b/app/models/contact_folder.py @@ -14,9 +14,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class ContactFolder(Base, TenantMixin): +class ContactFolder(Base, TenantMixin, OwnedMixin): """Hierarchical folder for organizing contacts. Folders are tenant-scoped and user-owned. A folder with parent_id=NULL diff --git a/app/models/user_preference.py b/app/models/user_preference.py index 187bf66..41f10eb 100644 --- a/app/models/user_preference.py +++ b/app/models/user_preference.py @@ -11,9 +11,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class UserPreference(Base, TenantMixin): +class UserPreference(Base, TenantMixin, OwnedMixin): """Per-user preference entry — stores a single UI preference as JSONB value. Keys are arbitrary strings (e.g. 'sidebar_collapsed', 'theme', 'active_tab'). diff --git a/app/models/workspace.py b/app/models/workspace.py index 9a2c3c2..7051a2a 100644 --- a/app/models/workspace.py +++ b/app/models/workspace.py @@ -30,9 +30,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class Workspace(Base, TenantMixin): +class Workspace(Base, TenantMixin, OwnedMixin): """A workspace is a UI/navigation context for a user. It defines which modules are visible, which dashboard widgets appear, diff --git a/app/plugins/builtins/ai_assistant/models.py b/app/plugins/builtins/ai_assistant/models.py index 2921ecb..ea78602 100644 --- a/app/plugins/builtins/ai_assistant/models.py +++ b/app/plugins/builtins/ai_assistant/models.py @@ -19,6 +19,7 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin # --- Providers --- @@ -101,7 +102,7 @@ class AIPreset(Base, TenantMixin): # --- Agents --- -class AIAgent(Base, TenantMixin): +class AIAgent(Base, TenantMixin, OwnedMixin): """AI agent with system prompt and assigned tools.""" __tablename__ = "ai_agents" @@ -128,7 +129,7 @@ class AIAgent(Base, TenantMixin): # --- Chat Sessions --- -class AIChatSession(Base, TenantMixin): +class AIChatSession(Base, TenantMixin, OwnedMixin): """Chat session for a user with a specific agent.""" __tablename__ = "ai_chat_sessions" diff --git a/app/plugins/builtins/ai_assistant/routes.py b/app/plugins/builtins/ai_assistant/routes.py index 258982e..1a46edf 100644 --- a/app/plugins/builtins/ai_assistant/routes.py +++ b/app/plugins/builtins/ai_assistant/routes.py @@ -330,9 +330,13 @@ async def list_agents( db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) - result = await db.execute( - select(AIAgent).where(AIAgent.tenant_id == tenant_id) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) + query = select(AIAgent).where(AIAgent.tenant_id == tenant_id) + query = await apply_visibility_filter( + db, query, "ai_agent", AIAgent, user_id, tenant_id, is_system_admin ) + result = await db.execute(query) agents = list(result.scalars().all()) return [agent_to_response(a) for a in agents] @@ -344,6 +348,7 @@ async def create_agent( db: AsyncSession = Depends(get_db), ): tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) await set_tenant_context(db, tenant_id) agent = AIAgent( @@ -355,6 +360,7 @@ async def create_agent( is_active=data.is_active, config=data.config, tenant_id=tenant_id, + owner_id=user_id, ) db.add(agent) await db.commit() @@ -422,10 +428,13 @@ async def list_sessions( ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) stmt = ( select(AIChatSession) .where(AIChatSession.tenant_id == tenant_id) - .where(AIChatSession.user_id == user_id) + ) + stmt = await apply_visibility_filter( + db, stmt, "ai_chat_session", AIChatSession, user_id, tenant_id, is_system_admin ) if is_sidebar is not None: stmt = stmt.where(AIChatSession.is_sidebar == is_sidebar) @@ -470,6 +479,7 @@ async def create_session( is_sidebar=data.is_sidebar, folder_id=folder_id, tenant_id=tenant_id, + owner_id=user_id, ) db.add(session) await db.commit() diff --git a/app/plugins/builtins/ai_proactive/models.py b/app/plugins/builtins/ai_proactive/models.py index 4426401..cd487cb 100644 --- a/app/plugins/builtins/ai_proactive/models.py +++ b/app/plugins/builtins/ai_proactive/models.py @@ -20,9 +20,10 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class ProactiveSuggestion(Base, TenantMixin): +class ProactiveSuggestion(Base, TenantMixin, OwnedMixin): """A proactive AI suggestion generated from user context.""" __tablename__ = "ai_proactive_suggestions" diff --git a/app/plugins/builtins/ai_proactive/routes.py b/app/plugins/builtins/ai_proactive/routes.py index ddcc1f0..58c8cee 100644 --- a/app/plugins/builtins/ai_proactive/routes.py +++ b/app/plugins/builtins/ai_proactive/routes.py @@ -16,6 +16,7 @@ from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db, set_tenant_context +from app.core.visibility import apply_visibility_filter from app.core.event_bus import get_event_bus from app.deps import get_current_user, require_permission from app.plugins.builtins.ai_proactive.models import ( diff --git a/app/plugins/builtins/automation/agent_routes.py b/app/plugins/builtins/automation/agent_routes.py index 7479d8b..0f6f116 100644 --- a/app/plugins/builtins/automation/agent_routes.py +++ b/app/plugins/builtins/automation/agent_routes.py @@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db, set_tenant_context +from app.core.visibility import apply_visibility_filter from app.deps import get_current_user, require_permission from app.plugins.builtins.automation.models import ( AgentDefinition, @@ -112,8 +113,11 @@ async def list_agents( ): """List agent definitions with optional filters.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) items, total = await AgentService.list( - db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset + db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset, + user_id=user_id, is_system_admin=is_system_admin, ) return AgentDefinitionListResponse( items=[_agent_to_response(a) for a in items], diff --git a/app/plugins/builtins/automation/models.py b/app/plugins/builtins/automation/models.py index d586cec..919c4f5 100644 --- a/app/plugins/builtins/automation/models.py +++ b/app/plugins/builtins/automation/models.py @@ -21,9 +21,10 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class AgentDefinition(Base, TenantMixin): +class AgentDefinition(Base, TenantMixin, OwnedMixin): """An AI agent definition — configures an LLM-powered agent with tools and behavior.""" __tablename__ = "automation_agent_definitions" @@ -89,7 +90,7 @@ class AgentVersion(Base, TenantMixin): ) -class AutomationDefinition(Base, TenantMixin): +class AutomationDefinition(Base, TenantMixin, OwnedMixin): """An automation workflow definition — event/schedule/manual triggered with conditions and actions.""" __tablename__ = "automation_definitions" diff --git a/app/plugins/builtins/automation/routes.py b/app/plugins/builtins/automation/routes.py index 836e1b1..a5a2234 100644 --- a/app/plugins/builtins/automation/routes.py +++ b/app/plugins/builtins/automation/routes.py @@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db, set_tenant_context +from app.core.visibility import apply_visibility_filter from app.deps import get_current_user, require_permission from app.plugins.builtins.automation.models import ( AutomationDefinition, @@ -112,9 +113,12 @@ async def list_automations( ): """List automation definitions with optional filters.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) items, total = await AutomationService.list( db, tenant_id, trigger_type=trigger_type, is_active=is_active, limit=limit, offset=offset, + user_id=user_id, is_system_admin=is_system_admin, ) return AutomationDefinitionListResponse( items=[_automation_to_response(a) for a in items], diff --git a/app/plugins/builtins/automation/services.py b/app/plugins/builtins/automation/services.py index e9c1b0a..4484607 100644 --- a/app/plugins/builtins/automation/services.py +++ b/app/plugins/builtins/automation/services.py @@ -11,6 +11,7 @@ from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select, text, update +from app.core.visibility import apply_visibility_filter from sqlalchemy.ext.asyncio import AsyncSession from app.plugins.builtins.automation.models import ( @@ -40,9 +41,15 @@ class AgentService: mode: str | None = None, limit: int = 50, offset: int = 0, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> tuple[list[AgentDefinition], int]: """List agent definitions with optional filters.""" query = select(AgentDefinition).where(AgentDefinition.tenant_id == tenant_id) + if user_id and not is_system_admin: + query = await apply_visibility_filter( + db, query, "agent_definition", AgentDefinition, user_id, tenant_id, is_system_admin + ) count_query = select(func.count()).select_from(AgentDefinition).where( AgentDefinition.tenant_id == tenant_id ) @@ -108,6 +115,7 @@ class AgentService: max_duration_seconds=data.get("max_duration_seconds", 300), budget_limit_usd=data.get("budget_limit_usd", 1.0), created_by=user_id, + owner_id=user_id, ) db.add(agent) await db.flush() @@ -291,11 +299,17 @@ class AutomationService: is_active: bool | None = None, limit: int = 50, offset: int = 0, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> tuple[list[AutomationDefinition], int]: """List automation definitions with optional filters.""" query = select(AutomationDefinition).where( AutomationDefinition.tenant_id == tenant_id ) + if user_id and not is_system_admin: + query = await apply_visibility_filter( + db, query, "automation_definition", AutomationDefinition, user_id, tenant_id, is_system_admin + ) count_query = ( select(func.count()) .select_from(AutomationDefinition) @@ -366,6 +380,7 @@ class AutomationService: is_active=data.get("is_active", True), dry_run=data.get("dry_run", False), created_by=user_id, + owner_id=user_id, ) db.add(automation) await db.flush() diff --git a/app/plugins/builtins/entity_links/models.py b/app/plugins/builtins/entity_links/models.py index c231336..f006356 100644 --- a/app/plugins/builtins/entity_links/models.py +++ b/app/plugins/builtins/entity_links/models.py @@ -9,9 +9,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class EntityLink(Base, TenantMixin): +class EntityLink(Base, TenantMixin, OwnedMixin): """N:M link between files/folders and companies/contacts.""" __tablename__ = "entity_links" diff --git a/app/plugins/builtins/entity_links/routes.py b/app/plugins/builtins/entity_links/routes.py index 54ca10d..fc15773 100644 --- a/app/plugins/builtins/entity_links/routes.py +++ b/app/plugins/builtins/entity_links/routes.py @@ -9,6 +9,7 @@ from sqlalchemy import select 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.plugins.builtins.entity_links.models import EntityLink from app.plugins.builtins.entity_links.schemas import EntityLinkRequest @@ -92,6 +93,7 @@ async def unlink_file_from_entity( ): """Remove a link between a file and an entity.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) fid = _parse_uuid(file_id, "file_id") entity_id = _parse_uuid(body.entity_id, "entity_id") @@ -119,6 +121,7 @@ async def list_file_links( ): """List all entities linked to a file.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) fid = _parse_uuid(file_id, "file_id") result = await db.execute( diff --git a/app/plugins/builtins/kommunikation/models.py b/app/plugins/builtins/kommunikation/models.py index c357254..14f4b29 100644 --- a/app/plugins/builtins/kommunikation/models.py +++ b/app/plugins/builtins/kommunikation/models.py @@ -21,9 +21,10 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class CommConversation(Base, TenantMixin): +class CommConversation(Base, TenantMixin, OwnedMixin): """Conversation / Room — tenant-scoped, supports pinning, locking, archiving.""" __tablename__ = "comm_conversations" diff --git a/app/plugins/builtins/kommunikation/routes.py b/app/plugins/builtins/kommunikation/routes.py index a195dab..abac79b 100644 --- a/app/plugins/builtins/kommunikation/routes.py +++ b/app/plugins/builtins/kommunikation/routes.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, 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.plugins.builtins.kommunikation.rbac import CommRBAC from app.plugins.builtins.kommunikation.schemas import ( diff --git a/app/plugins/builtins/kommunikation/services.py b/app/plugins/builtins/kommunikation/services.py index 8f64891..4fbfb32 100644 --- a/app/plugins/builtins/kommunikation/services.py +++ b/app/plugins/builtins/kommunikation/services.py @@ -258,6 +258,7 @@ async def create_conversation( conv = CommConversation( tenant_id=tenant_id, title=title, + owner_id=user_id, is_direct=is_direct, created_by=user_id, created_by_type="user", @@ -1068,6 +1069,7 @@ async def create_plugin_room( conv = CommConversation( tenant_id=tenant_id, title=title, + owner_id=user_id, is_locked=True, locked_by=plugin_name, is_direct=False, diff --git a/app/plugins/builtins/mcp_client/models.py b/app/plugins/builtins/mcp_client/models.py index 8c9e0d9..714fef0 100644 --- a/app/plugins/builtins/mcp_client/models.py +++ b/app/plugins/builtins/mcp_client/models.py @@ -10,9 +10,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class McpServerConfig(Base, TenantMixin): +class McpServerConfig(Base, TenantMixin, OwnedMixin): """Configuration for an external MCP server — tenant-scoped.""" __tablename__ = "mcp_server_configs" diff --git a/app/plugins/builtins/mcp_client/routes.py b/app/plugins/builtins/mcp_client/routes.py index d0f1d5b..4810917 100644 --- a/app/plugins/builtins/mcp_client/routes.py +++ b/app/plugins/builtins/mcp_client/routes.py @@ -12,6 +12,7 @@ from sqlalchemy import select, update 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.plugins.builtins.mcp_client.client import McpClient from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel @@ -50,7 +51,13 @@ async def list_mcp_servers( current_user: dict[str, Any] = Depends(require_permission("mcp-client:read")), ) -> list[McpServerConfigResponse]: """List all configured MCP servers for the current tenant.""" - stmt = select(McpServerConfigModel).where(McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"])) + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) + stmt = select(McpServerConfigModel).where(McpServerConfigModel.tenant_id == tenant_id) + stmt = await apply_visibility_filter( + db, stmt, "mcp_server_config", McpServerConfigModel, user_id, tenant_id, is_system_admin + ) result = await db.execute(stmt) configs = result.scalars().all() return [_config_to_response(c) for c in configs] @@ -71,6 +78,7 @@ async def create_mcp_server( enabled=body.enabled, description=body.description, created_by=uuid.UUID(current_user["user_id"]), + owner_id=uuid.UUID(current_user["user_id"]), ) db.add(cfg) await db.commit() diff --git a/app/plugins/builtins/permissions/models.py b/app/plugins/builtins/permissions/models.py index e8594b1..c493bf2 100644 --- a/app/plugins/builtins/permissions/models.py +++ b/app/plugins/builtins/permissions/models.py @@ -10,6 +10,7 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin class Permission(Base, TenantMixin): @@ -37,7 +38,7 @@ class Permission(Base, TenantMixin): access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="read") -class ShareLink(Base, TenantMixin): +class ShareLink(Base, TenantMixin, OwnedMixin): """Public share link for a file — optional password and expiry.""" __tablename__ = "share_links" diff --git a/app/plugins/builtins/permissions/routes.py b/app/plugins/builtins/permissions/routes.py index 38457b5..787cbe9 100644 --- a/app/plugins/builtins/permissions/routes.py +++ b/app/plugins/builtins/permissions/routes.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import hash_password, verify_password 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.plugins.builtins.permissions.models import Permission, ShareLink from app.plugins.builtins.permissions.schemas import ( @@ -48,6 +49,7 @@ async def list_permissions( ): """List all permissions for a file.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) fid = _parse_uuid(file_id, "file_id") result = await db.execute( @@ -78,6 +80,7 @@ async def grant_permission( ): """Grant a permission on a file to a user.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) fid = _parse_uuid(file_id, "file_id") user_id = _parse_uuid(body.user_id, "user_id") group_id = _parse_uuid(body.group_id, "group_id") if body.group_id else None @@ -123,6 +126,7 @@ async def revoke_permission( ): """Revoke all permissions for a user on a file.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) fid = _parse_uuid(file_id, "file_id") uid = _parse_uuid(user_id, "user_id") diff --git a/app/plugins/builtins/report_generator/models.py b/app/plugins/builtins/report_generator/models.py index e5895e8..8533b50 100644 --- a/app/plugins/builtins/report_generator/models.py +++ b/app/plugins/builtins/report_generator/models.py @@ -9,9 +9,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class ReportTemplate(Base, TenantMixin): +class ReportTemplate(Base, TenantMixin, OwnedMixin): """Report template entity — Jinja2 or SQL template, tenant-scoped, soft-deletable.""" __tablename__ = "report_templates" @@ -35,7 +36,7 @@ class ReportTemplate(Base, TenantMixin): created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) -class ReportInstance(Base, TenantMixin): +class ReportInstance(Base, TenantMixin, OwnedMixin): """Report instance entity — a generated report, tenant-scoped.""" __tablename__ = "report_instances" diff --git a/app/plugins/builtins/report_generator/routes.py b/app/plugins/builtins/report_generator/routes.py index c943711..39d9cb7 100644 --- a/app/plugins/builtins/report_generator/routes.py +++ b/app/plugins/builtins/report_generator/routes.py @@ -16,6 +16,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.contact import Contact +from app.core.visibility import apply_visibility_filter from app.models.audit import AuditLog from app.core.db import get_db, set_tenant_context @@ -292,12 +293,16 @@ async def list_templates( ): """List all report templates for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) - result = await db.execute( - select(ReportTemplate).where( - ReportTemplate.tenant_id == tenant_id, - ReportTemplate.deleted_at.is_(None), - ) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) + query = select(ReportTemplate).where( + ReportTemplate.tenant_id == tenant_id, + ReportTemplate.deleted_at.is_(None), ) + query = await apply_visibility_filter( + db, query, "report_template", ReportTemplate, user_id, tenant_id, is_system_admin + ) + result = await db.execute(query) templates = result.scalars().all() return [_template_to_response(t).model_dump() for t in templates] @@ -319,6 +324,7 @@ async def create_template( content=body.content, output_format=body.output_format, created_by=user_id, + owner_id=user_id, ) db.add(template) await db.flush() diff --git a/app/plugins/builtins/tags/models.py b/app/plugins/builtins/tags/models.py index dbf3550..7db41ab 100644 --- a/app/plugins/builtins/tags/models.py +++ b/app/plugins/builtins/tags/models.py @@ -9,9 +9,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin +from app.models.owned_mixin import OwnedMixin -class Tag(Base, TenantMixin): +class Tag(Base, TenantMixin, OwnedMixin): """Tag entity — globally managed, tenant-scoped.""" __tablename__ = "tags" diff --git a/app/plugins/builtins/tags/routes.py b/app/plugins/builtins/tags/routes.py index d0e908d..3303b1e 100644 --- a/app/plugins/builtins/tags/routes.py +++ b/app/plugins/builtins/tags/routes.py @@ -9,6 +9,7 @@ from sqlalchemy import delete, func, select 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.plugins.builtins.tags.models import Tag, TagAssignment from app.plugins.builtins.tags.schemas import ( @@ -40,6 +41,8 @@ async def list_tags( ): """List all tags with entity counts.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) # Subquery for entity counts per tag count_sq = ( @@ -52,12 +55,16 @@ async def list_tags( .subquery() ) - result = await db.execute( + query = ( select(Tag, func.coalesce(count_sq.c.entity_count, 0)) .outerjoin(count_sq, Tag.id == count_sq.c.tag_id) .where(Tag.tenant_id == tenant_id) .order_by(Tag.name) ) + query = await apply_visibility_filter( + db, query, "tag", Tag, user_id, tenant_id, is_system_admin + ) + result = await db.execute(query) rows = result.all() return [ @@ -79,6 +86,7 @@ async def create_tag( ): """Create a new tag.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) # Check name uniqueness within tenant existing = await db.execute( @@ -87,7 +95,7 @@ async def create_tag( if existing.scalar_one_or_none() is not None: raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"}) - tag = Tag(tenant_id=tenant_id, name=body.name, color=body.color) + tag = Tag(tenant_id=tenant_id, name=body.name, color=body.color, owner_id=user_id) db.add(tag) await db.flush() return { diff --git a/app/plugins/builtins/tasks/routes.py b/app/plugins/builtins/tasks/routes.py index 92b4334..9a74168 100644 --- a/app/plugins/builtins/tasks/routes.py +++ b/app/plugins/builtins/tasks/routes.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status 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.plugins.builtins.tasks import services from app.plugins.builtins.tasks.schemas import ( @@ -43,12 +44,15 @@ async def list_tasks( ): """List tasks with filtering and pagination.""" tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_system_admin = current_user.get("is_system_admin", False) return await services.list_tasks( db, tenant_id, page=page, page_size=page_size, status=status, priority=priority, assigned_to=assigned_to, contact_id=contact_id, search=search, + user_id=user_id, is_system_admin=is_system_admin, ) diff --git a/app/plugins/builtins/tasks/services.py b/app/plugins/builtins/tasks/services.py index c2f8ba4..94f018a 100644 --- a/app/plugins/builtins/tasks/services.py +++ b/app/plugins/builtins/tasks/services.py @@ -9,6 +9,7 @@ from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.visibility import apply_visibility_filter from app.plugins.builtins.tasks.models import Task @@ -40,10 +41,17 @@ async def list_tasks( assigned_to: str | None = None, contact_id: str | None = None, search: str | None = None, + user_id: uuid.UUID | None = None, + is_system_admin: bool = False, ) -> dict[str, Any]: """List tasks with filtering and pagination.""" query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None)) + if user_id and not is_system_admin: + query = await apply_visibility_filter( + db, query, "task", Task, user_id, tenant_id, is_system_admin + ) + if status: query = query.where(Task.status == status) if priority: @@ -103,6 +111,7 @@ async def create_task( assigned_to=uuid.UUID(data["assigned_to"]) if data.get("assigned_to") else None, contact_id=uuid.UUID(data["contact_id"]) if data.get("contact_id") else None, created_by=user_id, + owner_id=user_id, ) db.add(task) await db.flush() diff --git a/app/services/entity_permission_service.py b/app/services/entity_permission_service.py index 54e1042..22b8fdb 100644 --- a/app/services/entity_permission_service.py +++ b/app/services/entity_permission_service.py @@ -36,7 +36,6 @@ from app.models.sequence import Sequence from app.models.saved_filter import SavedFilter from app.models.saved_view import SavedView from app.models.webhook import Webhook -from app.models.notification import Notification from app.models.custom_field_definition import CustomFieldDefinition from app.models.contact_folder import ContactFolder @@ -56,20 +55,46 @@ ENTITY_MODELS: dict[str, type] = { "saved_filter": SavedFilter, "saved_view": SavedView, "webhook": Webhook, - "notification": Notification, "custom_field_definition": CustomFieldDefinition, "contact_folder": ContactFolder, } -# Try to add plugin models if available +# Core models with OwnedMixin (Phase 2 additions) try: - from app.plugins.builtins.dms.models import DmsFile - ENTITY_MODELS["dms_file"] = DmsFile + from app.models.entity_attachment import EntityAttachment + ENTITY_MODELS["entity_attachment"] = EntityAttachment except ImportError: pass try: - from app.plugins.builtins.calendar.models import CalendarEvent - ENTITY_MODELS["calendar_event"] = CalendarEvent + from app.models.entity_history import EntityHistory + ENTITY_MODELS["entity_history"] = EntityHistory +except ImportError: + pass + +# Plugin models if available +try: + from app.plugins.builtins.dms.models import File as DmsFile + ENTITY_MODELS["file"] = DmsFile +except ImportError: + pass +try: + from app.plugins.builtins.dms.models import Folder as DmsFolder + ENTITY_MODELS["folder"] = DmsFolder +except ImportError: + pass +try: + from app.plugins.builtins.calendar.models import CalendarEntry + ENTITY_MODELS["calendar_event"] = CalendarEntry +except ImportError: + pass +try: + from app.plugins.builtins.calendar.models import Calendar + ENTITY_MODELS["calendar"] = Calendar +except ImportError: + pass +try: + from app.plugins.builtins.calendar.models import Subtask + ENTITY_MODELS["subtask"] = Subtask except ImportError: pass try: @@ -78,8 +103,8 @@ try: except ImportError: pass try: - from app.plugins.builtins.mail.models import Mailbox - ENTITY_MODELS["mailbox"] = Mailbox + from app.plugins.builtins.mail.models import MailAccount + ENTITY_MODELS["mailbox"] = MailAccount except ImportError: pass