- 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:
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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').
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user