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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base, TenantMixin 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. """Hierarchical folder for organizing contacts.
Folders are tenant-scoped and user-owned. A folder with parent_id=NULL 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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. """Per-user preference entry — stores a single UI preference as JSONB value.
Keys are arbitrary strings (e.g. 'sidebar_collapsed', 'theme', 'active_tab'). 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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. """A workspace is a UI/navigation context for a user.
It defines which modules are visible, which dashboard widgets appear, 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
# --- Providers --- # --- Providers ---
@@ -101,7 +102,7 @@ class AIPreset(Base, TenantMixin):
# --- Agents --- # --- Agents ---
class AIAgent(Base, TenantMixin): class AIAgent(Base, TenantMixin, OwnedMixin):
"""AI agent with system prompt and assigned tools.""" """AI agent with system prompt and assigned tools."""
__tablename__ = "ai_agents" __tablename__ = "ai_agents"
@@ -128,7 +129,7 @@ class AIAgent(Base, TenantMixin):
# --- Chat Sessions --- # --- Chat Sessions ---
class AIChatSession(Base, TenantMixin): class AIChatSession(Base, TenantMixin, OwnedMixin):
"""Chat session for a user with a specific agent.""" """Chat session for a user with a specific agent."""
__tablename__ = "ai_chat_sessions" __tablename__ = "ai_chat_sessions"
+13 -3
View File
@@ -330,9 +330,13 @@ async def list_agents(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute( user_id = uuid.UUID(current_user["user_id"])
select(AIAgent).where(AIAgent.tenant_id == tenant_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()) agents = list(result.scalars().all())
return [agent_to_response(a) for a in agents] return [agent_to_response(a) for a in agents]
@@ -344,6 +348,7 @@ async def create_agent(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
await set_tenant_context(db, tenant_id) await set_tenant_context(db, tenant_id)
agent = AIAgent( agent = AIAgent(
@@ -355,6 +360,7 @@ async def create_agent(
is_active=data.is_active, is_active=data.is_active,
config=data.config, config=data.config,
tenant_id=tenant_id, tenant_id=tenant_id,
owner_id=user_id,
) )
db.add(agent) db.add(agent)
await db.commit() await db.commit()
@@ -422,10 +428,13 @@ async def list_sessions(
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
stmt = ( stmt = (
select(AIChatSession) select(AIChatSession)
.where(AIChatSession.tenant_id == tenant_id) .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: if is_sidebar is not None:
stmt = stmt.where(AIChatSession.is_sidebar == is_sidebar) stmt = stmt.where(AIChatSession.is_sidebar == is_sidebar)
@@ -470,6 +479,7 @@ async def create_session(
is_sidebar=data.is_sidebar, is_sidebar=data.is_sidebar,
folder_id=folder_id, folder_id=folder_id,
tenant_id=tenant_id, tenant_id=tenant_id,
owner_id=user_id,
) )
db.add(session) db.add(session)
await db.commit() 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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.""" """A proactive AI suggestion generated from user context."""
__tablename__ = "ai_proactive_suggestions" __tablename__ = "ai_proactive_suggestions"
@@ -16,6 +16,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, set_tenant_context 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.core.event_bus import get_event_bus
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.plugins.builtins.ai_proactive.models import ( 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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, set_tenant_context 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.deps import get_current_user, require_permission
from app.plugins.builtins.automation.models import ( from app.plugins.builtins.automation.models import (
AgentDefinition, AgentDefinition,
@@ -112,8 +113,11 @@ async def list_agents(
): ):
"""List agent definitions with optional filters.""" """List agent definitions with optional filters."""
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)
items, total = await AgentService.list( 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( return AgentDefinitionListResponse(
items=[_agent_to_response(a) for a in items], 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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.""" """An AI agent definition — configures an LLM-powered agent with tools and behavior."""
__tablename__ = "automation_agent_definitions" __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.""" """An automation workflow definition — event/schedule/manual triggered with conditions and actions."""
__tablename__ = "automation_definitions" __tablename__ = "automation_definitions"
@@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, set_tenant_context 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.deps import get_current_user, require_permission
from app.plugins.builtins.automation.models import ( from app.plugins.builtins.automation.models import (
AutomationDefinition, AutomationDefinition,
@@ -112,9 +113,12 @@ async def list_automations(
): ):
"""List automation definitions with optional filters.""" """List automation definitions with optional filters."""
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)
items, total = await AutomationService.list( items, total = await AutomationService.list(
db, tenant_id, trigger_type=trigger_type, is_active=is_active, db, tenant_id, trigger_type=trigger_type, is_active=is_active,
limit=limit, offset=offset, limit=limit, offset=offset,
user_id=user_id, is_system_admin=is_system_admin,
) )
return AutomationDefinitionListResponse( return AutomationDefinitionListResponse(
items=[_automation_to_response(a) for a in items], items=[_automation_to_response(a) for a in items],
@@ -11,6 +11,7 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from sqlalchemy import func, select, text, update from sqlalchemy import func, select, text, update
from app.core.visibility import apply_visibility_filter
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.automation.models import ( from app.plugins.builtins.automation.models import (
@@ -40,9 +41,15 @@ class AgentService:
mode: str | None = None, mode: str | None = None,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> tuple[list[AgentDefinition], int]: ) -> tuple[list[AgentDefinition], int]:
"""List agent definitions with optional filters.""" """List agent definitions with optional filters."""
query = select(AgentDefinition).where(AgentDefinition.tenant_id == tenant_id) 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( count_query = select(func.count()).select_from(AgentDefinition).where(
AgentDefinition.tenant_id == tenant_id AgentDefinition.tenant_id == tenant_id
) )
@@ -108,6 +115,7 @@ class AgentService:
max_duration_seconds=data.get("max_duration_seconds", 300), max_duration_seconds=data.get("max_duration_seconds", 300),
budget_limit_usd=data.get("budget_limit_usd", 1.0), budget_limit_usd=data.get("budget_limit_usd", 1.0),
created_by=user_id, created_by=user_id,
owner_id=user_id,
) )
db.add(agent) db.add(agent)
await db.flush() await db.flush()
@@ -291,11 +299,17 @@ class AutomationService:
is_active: bool | None = None, is_active: bool | None = None,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> tuple[list[AutomationDefinition], int]: ) -> tuple[list[AutomationDefinition], int]:
"""List automation definitions with optional filters.""" """List automation definitions with optional filters."""
query = select(AutomationDefinition).where( query = select(AutomationDefinition).where(
AutomationDefinition.tenant_id == tenant_id 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 = ( count_query = (
select(func.count()) select(func.count())
.select_from(AutomationDefinition) .select_from(AutomationDefinition)
@@ -366,6 +380,7 @@ class AutomationService:
is_active=data.get("is_active", True), is_active=data.get("is_active", True),
dry_run=data.get("dry_run", False), dry_run=data.get("dry_run", False),
created_by=user_id, created_by=user_id,
owner_id=user_id,
) )
db.add(automation) db.add(automation)
await db.flush() 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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.""" """N:M link between files/folders and companies/contacts."""
__tablename__ = "entity_links" __tablename__ = "entity_links"
@@ -9,6 +9,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.plugins.builtins.entity_links.models import EntityLink from app.plugins.builtins.entity_links.models import EntityLink
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
@@ -92,6 +93,7 @@ async def unlink_file_from_entity(
): ):
"""Remove a link between a file and an entity.""" """Remove a link between a file and an entity."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id") fid = _parse_uuid(file_id, "file_id")
entity_id = _parse_uuid(body.entity_id, "entity_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.""" """List all entities linked to a file."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id") fid = _parse_uuid(file_id, "file_id")
result = await db.execute( 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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.""" """Conversation / Room — tenant-scoped, supports pinning, locking, archiving."""
__tablename__ = "comm_conversations" __tablename__ = "comm_conversations"
@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File,
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.plugins.builtins.kommunikation.rbac import CommRBAC from app.plugins.builtins.kommunikation.rbac import CommRBAC
from app.plugins.builtins.kommunikation.schemas import ( from app.plugins.builtins.kommunikation.schemas import (
@@ -258,6 +258,7 @@ async def create_conversation(
conv = CommConversation( conv = CommConversation(
tenant_id=tenant_id, tenant_id=tenant_id,
title=title, title=title,
owner_id=user_id,
is_direct=is_direct, is_direct=is_direct,
created_by=user_id, created_by=user_id,
created_by_type="user", created_by_type="user",
@@ -1068,6 +1069,7 @@ async def create_plugin_room(
conv = CommConversation( conv = CommConversation(
tenant_id=tenant_id, tenant_id=tenant_id,
title=title, title=title,
owner_id=user_id,
is_locked=True, is_locked=True,
locked_by=plugin_name, locked_by=plugin_name,
is_direct=False, 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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.""" """Configuration for an external MCP server — tenant-scoped."""
__tablename__ = "mcp_server_configs" __tablename__ = "mcp_server_configs"
+9 -1
View File
@@ -12,6 +12,7 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.plugins.builtins.mcp_client.client import McpClient from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel 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")), current_user: dict[str, Any] = Depends(require_permission("mcp-client:read")),
) -> list[McpServerConfigResponse]: ) -> list[McpServerConfigResponse]:
"""List all configured MCP servers for the current tenant.""" """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) result = await db.execute(stmt)
configs = result.scalars().all() configs = result.scalars().all()
return [_config_to_response(c) for c in configs] return [_config_to_response(c) for c in configs]
@@ -71,6 +78,7 @@ async def create_mcp_server(
enabled=body.enabled, enabled=body.enabled,
description=body.description, description=body.description,
created_by=uuid.UUID(current_user["user_id"]), created_by=uuid.UUID(current_user["user_id"]),
owner_id=uuid.UUID(current_user["user_id"]),
) )
db.add(cfg) db.add(cfg)
await db.commit() 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
class Permission(Base, TenantMixin): class Permission(Base, TenantMixin):
@@ -37,7 +38,7 @@ class Permission(Base, TenantMixin):
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="read") 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.""" """Public share link for a file — optional password and expiry."""
__tablename__ = "share_links" __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.auth import hash_password, verify_password
from app.core.db import get_db 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.deps import get_current_user, require_permission
from app.plugins.builtins.permissions.models import Permission, ShareLink from app.plugins.builtins.permissions.models import Permission, ShareLink
from app.plugins.builtins.permissions.schemas import ( from app.plugins.builtins.permissions.schemas import (
@@ -48,6 +49,7 @@ async def list_permissions(
): ):
"""List all permissions for a file.""" """List all permissions for a file."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id") fid = _parse_uuid(file_id, "file_id")
result = await db.execute( result = await db.execute(
@@ -78,6 +80,7 @@ async def grant_permission(
): ):
"""Grant a permission on a file to a user.""" """Grant a permission on a file to a user."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id") fid = _parse_uuid(file_id, "file_id")
user_id = _parse_uuid(body.user_id, "user_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 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.""" """Revoke all permissions for a user on a file."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id") fid = _parse_uuid(file_id, "file_id")
uid = _parse_uuid(user_id, "user_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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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.""" """Report template entity — Jinja2 or SQL template, tenant-scoped, soft-deletable."""
__tablename__ = "report_templates" __tablename__ = "report_templates"
@@ -35,7 +36,7 @@ class ReportTemplate(Base, TenantMixin):
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) 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.""" """Report instance entity — a generated report, tenant-scoped."""
__tablename__ = "report_instances" __tablename__ = "report_instances"
@@ -16,6 +16,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.contact import Contact from app.models.contact import Contact
from app.core.visibility import apply_visibility_filter
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.core.db import get_db, set_tenant_context 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.""" """List all report templates for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute( user_id = uuid.UUID(current_user["user_id"])
select(ReportTemplate).where( is_system_admin = current_user.get("is_system_admin", False)
ReportTemplate.tenant_id == tenant_id, query = select(ReportTemplate).where(
ReportTemplate.deleted_at.is_(None), 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() templates = result.scalars().all()
return [_template_to_response(t).model_dump() for t in templates] return [_template_to_response(t).model_dump() for t in templates]
@@ -319,6 +324,7 @@ async def create_template(
content=body.content, content=body.content,
output_format=body.output_format, output_format=body.output_format,
created_by=user_id, created_by=user_id,
owner_id=user_id,
) )
db.add(template) db.add(template)
await db.flush() 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 sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin 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.""" """Tag entity — globally managed, tenant-scoped."""
__tablename__ = "tags" __tablename__ = "tags"
+10 -2
View File
@@ -9,6 +9,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db 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.deps import get_current_user, require_permission
from app.plugins.builtins.tags.models import Tag, TagAssignment from app.plugins.builtins.tags.models import Tag, TagAssignment
from app.plugins.builtins.tags.schemas import ( from app.plugins.builtins.tags.schemas import (
@@ -40,6 +41,8 @@ async def list_tags(
): ):
"""List all tags with entity counts.""" """List all tags with entity counts."""
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)
# Subquery for entity counts per tag # Subquery for entity counts per tag
count_sq = ( count_sq = (
@@ -52,12 +55,16 @@ async def list_tags(
.subquery() .subquery()
) )
result = await db.execute( query = (
select(Tag, func.coalesce(count_sq.c.entity_count, 0)) select(Tag, func.coalesce(count_sq.c.entity_count, 0))
.outerjoin(count_sq, Tag.id == count_sq.c.tag_id) .outerjoin(count_sq, Tag.id == count_sq.c.tag_id)
.where(Tag.tenant_id == tenant_id) .where(Tag.tenant_id == tenant_id)
.order_by(Tag.name) .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() rows = result.all()
return [ return [
@@ -79,6 +86,7 @@ async def create_tag(
): ):
"""Create a new tag.""" """Create a new tag."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Check name uniqueness within tenant # Check name uniqueness within tenant
existing = await db.execute( existing = await db.execute(
@@ -87,7 +95,7 @@ async def create_tag(
if existing.scalar_one_or_none() is not None: if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"}) 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) db.add(tag)
await db.flush() await db.flush()
return { return {
+4
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.plugins.builtins.tasks import services from app.plugins.builtins.tasks import services
from app.plugins.builtins.tasks.schemas import ( from app.plugins.builtins.tasks.schemas import (
@@ -43,12 +44,15 @@ async def list_tasks(
): ):
"""List tasks with filtering and pagination.""" """List tasks with filtering and pagination."""
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)
return await services.list_tasks( return await services.list_tasks(
db, tenant_id, db, tenant_id,
page=page, page_size=page_size, page=page, page_size=page_size,
status=status, priority=priority, status=status, priority=priority,
assigned_to=assigned_to, contact_id=contact_id, assigned_to=assigned_to, contact_id=contact_id,
search=search, 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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter
from app.plugins.builtins.tasks.models import Task from app.plugins.builtins.tasks.models import Task
@@ -40,10 +41,17 @@ async def list_tasks(
assigned_to: str | None = None, assigned_to: str | None = None,
contact_id: str | None = None, contact_id: str | None = None,
search: str | None = None, search: str | None = None,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""List tasks with filtering and pagination.""" """List tasks with filtering and pagination."""
query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None)) 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: if status:
query = query.where(Task.status == status) query = query.where(Task.status == status)
if priority: if priority:
@@ -103,6 +111,7 @@ async def create_task(
assigned_to=uuid.UUID(data["assigned_to"]) if data.get("assigned_to") else None, 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, contact_id=uuid.UUID(data["contact_id"]) if data.get("contact_id") else None,
created_by=user_id, created_by=user_id,
owner_id=user_id,
) )
db.add(task) db.add(task)
await db.flush() 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_filter import SavedFilter
from app.models.saved_view import SavedView from app.models.saved_view import SavedView
from app.models.webhook import Webhook from app.models.webhook import Webhook
from app.models.notification import Notification
from app.models.custom_field_definition import CustomFieldDefinition from app.models.custom_field_definition import CustomFieldDefinition
from app.models.contact_folder import ContactFolder from app.models.contact_folder import ContactFolder
@@ -56,20 +55,46 @@ ENTITY_MODELS: dict[str, type] = {
"saved_filter": SavedFilter, "saved_filter": SavedFilter,
"saved_view": SavedView, "saved_view": SavedView,
"webhook": Webhook, "webhook": Webhook,
"notification": Notification,
"custom_field_definition": CustomFieldDefinition, "custom_field_definition": CustomFieldDefinition,
"contact_folder": ContactFolder, "contact_folder": ContactFolder,
} }
# Try to add plugin models if available # Core models with OwnedMixin (Phase 2 additions)
try: try:
from app.plugins.builtins.dms.models import DmsFile from app.models.entity_attachment import EntityAttachment
ENTITY_MODELS["dms_file"] = DmsFile ENTITY_MODELS["entity_attachment"] = EntityAttachment
except ImportError: except ImportError:
pass pass
try: try:
from app.plugins.builtins.calendar.models import CalendarEvent from app.models.entity_history import EntityHistory
ENTITY_MODELS["calendar_event"] = CalendarEvent 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: except ImportError:
pass pass
try: try:
@@ -78,8 +103,8 @@ try:
except ImportError: except ImportError:
pass pass
try: try:
from app.plugins.builtins.mail.models import Mailbox from app.plugins.builtins.mail.models import MailAccount
ENTITY_MODELS["mailbox"] = Mailbox ENTITY_MODELS["mailbox"] = MailAccount
except ImportError: except ImportError:
pass pass