Phase 2: Visibility Filter & Owner ID
Check Cross-Plugin Imports / check (push) Has been cancelled

- 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
This commit is contained in:
Agent Zero
2026-08-04 00:03:29 +02:00
parent 93a330ae40
commit e17b9c9e56
29 changed files with 391 additions and 36 deletions
+170
View File
@@ -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
@@ -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")
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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').
+2 -1
View File
@@ -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,
+3 -2
View File
@@ -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"
+13 -3
View File
@@ -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()
+2 -1
View File
@@ -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"
@@ -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 (
@@ -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],
+3 -2
View File
@@ -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"
@@ -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],
@@ -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()
+2 -1
View File
@@ -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"
@@ -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(
+2 -1
View File
@@ -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"
@@ -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 (
@@ -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,
+2 -1
View File
@@ -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"
+9 -1
View File
@@ -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()
+2 -1
View File
@@ -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"
@@ -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")
@@ -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"
@@ -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()
+2 -1
View File
@@ -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"
+10 -2
View File
@@ -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 {
+4
View File
@@ -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,
)
+9
View File
@@ -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()
+34 -9
View File
@@ -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