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